diff --git a/arcade/__init__.py b/arcade/__init__.py index 2a470690a8..37fb4f9673 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -84,7 +84,6 @@ def configure_logging(level: Optional[int] = None): from .window_commands import unschedule from .window_commands import schedule_once -from .camera import SimpleCamera, Camera from .sections import Section, SectionManager from .application import MOUSE_BUTTON_LEFT @@ -219,6 +218,7 @@ def configure_logging(level: Optional[int] = None): # Module imports from arcade import color as color from arcade import csscolor as csscolor +from arcade import camera as camera from arcade import key as key from arcade import resources as resources from arcade import types as types @@ -240,8 +240,6 @@ def configure_logging(level: Optional[int] = None): 'AnimatedWalkingSprite', 'AnimationKeyframe', 'ArcadeContext', - 'Camera', - 'SimpleCamera', 'ControllerManager', 'FACE_DOWN', 'FACE_LEFT', diff --git a/arcade/application.py b/arcade/application.py index e2d96fc8a8..3a3ff707e9 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -22,6 +22,8 @@ from arcade.types import Color, RGBA255, RGBA255OrNormalized from arcade import SectionManager from arcade.utils import is_raspberry_pi +from arcade.camera import Projector +from arcade.camera.default import DefaultProjector LOG = logging.getLogger(__name__) @@ -201,17 +203,17 @@ def __init__( # self.invalid = False set_window(self) + self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) + set_viewport(0, self.width, 0, self.height) + self._background_color: Color = TRANSPARENT_BLACK + self._current_view: Optional[View] = None - self.current_camera: Optional[arcade.SimpleCamera] = None + self.current_camera: Projector = DefaultProjector(window=self) self.textbox_time = 0.0 self.key: Optional[int] = None self.flip_count: int = 0 self.static_display: bool = False - self._ctx: ArcadeContext = ArcadeContext(self, gc_mode=gc_mode, gl_api=gl_api) - set_viewport(0, self.width, 0, self.height) - self._background_color: Color = TRANSPARENT_BLACK - # See if we should center the window if center_window: self.center_window() diff --git a/arcade/camera.py b/arcade/camera.py deleted file mode 100644 index 4fdaabf246..0000000000 --- a/arcade/camera.py +++ /dev/null @@ -1,575 +0,0 @@ -""" -Camera class -""" -import math -from typing import TYPE_CHECKING, List, Optional, Tuple, Union - -from pyglet.math import Mat4, Vec2, Vec3 - -import arcade -from arcade.types import Point -from arcade.math import get_distance - -if TYPE_CHECKING: - from arcade import Sprite, SpriteList - -# type aliases -FourIntTuple = Tuple[int, int, int, int] -FourFloatTuple = Tuple[float, float, float, float] - -__all__ = [ - "SimpleCamera", - "Camera" -] - - -class SimpleCamera: - """ - A simple camera that allows to change the viewport, the projection and can move around. - That's it. - See arcade.Camera for more advance stuff. - - :param viewport: Size of the viewport: (left, bottom, width, height) - :param projection: Space to allocate in the viewport of the camera (left, right, bottom, top) - """ - def __init__( - self, - *, - viewport: Optional[FourIntTuple] = None, - projection: Optional[FourFloatTuple] = None, - window: Optional["arcade.Window"] = None, - ) -> None: - # Reference to Context, used to update projection matrix - self._window: "arcade.Window" = window or arcade.get_window() - - # store the viewport and projection tuples - # viewport is the space the camera will hold on the screen (left, bottom, width, height) - self._viewport: FourIntTuple = viewport or (0, 0, self._window.width, self._window.height) - - # projection is what you want to project into the camera viewport (left, right, bottom, top) - self._projection: FourFloatTuple = projection or (0, self._window.width, - 0, self._window.height) - if viewport is not None and projection is None: - # if viewport is provided but projection is not, projection - # will match the provided viewport - self._projection = (viewport[0], viewport[2], viewport[1], viewport[3]) - - # Matrices - - # Projection Matrix is used to apply the camera viewport size - self._projection_matrix: Mat4 = Mat4() - # View Matrix is what the camera is looking at(position) - self._view_matrix: Mat4 = Mat4() - # We multiply projection and view matrices to get combined, - # this is what actually gets sent to GL context - self._combined_matrix: Mat4 = Mat4() - - # Position - self.position: Vec2 = Vec2(0, 0) - - # Camera movement - self.goal_position: Vec2 = Vec2(0, 0) - self.move_speed: float = 1.0 # 1.0 is instant - self.moving: bool = False - - # Init matrixes - # This will pre-compute the projection, view and combined matrixes - self._set_projection_matrix(update_combined_matrix=False) - self._set_view_matrix() - - @property - def viewport_width(self) -> int: - """ Returns the width of the viewport """ - return self._viewport[2] - - @property - def viewport_height(self) -> int: - """ Returns the height of the viewport """ - return self._viewport[3] - - @property - def viewport(self) -> FourIntTuple: - """ The space the camera will hold on the screen (left, bottom, width, height) """ - return self._viewport - - @viewport.setter - def viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - self.set_viewport(viewport) - - def set_viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - self._viewport = viewport or (0, 0, self._window.width, self._window.height) - - # the viewport affects the view matrix - self._set_view_matrix() - - @property - def projection(self) -> FourFloatTuple: - """ - The dimensions of the space to project in the camera viewport (left, right, bottom, top). - The projection is what you want to project into the camera viewport. - """ - return self._projection - - @projection.setter - def projection(self, new_projection: FourFloatTuple) -> None: - """ - Update the projection of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._projection = new_projection or (0, self._window.width, 0, self._window.height) - self._set_projection_matrix() - - @property - def viewport_to_projection_width_ratio(self): - """ The ratio of viewport width to projection width """ - return self.viewport_width / (self._projection[1] - self._projection[0]) - - @property - def viewport_to_projection_height_ratio(self): - """ The ratio of viewport height to projection height """ - return self.viewport_height / (self._projection[3] - self._projection[2]) - - @property - def projection_to_viewport_width_ratio(self): - """ The ratio of projection width to viewport width """ - return (self._projection[1] - self._projection[0]) / self.viewport_width - - @property - def projection_to_viewport_height_ratio(self): - """ The ratio of projection height to viewport height """ - return (self._projection[3] - self._projection[2]) / self.viewport_height - - def _set_projection_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the projection and combined matrix - - :param bool update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - self._projection_matrix = Mat4.orthogonal_projection(*self._projection, -100, 100) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_view_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the view and combined matrix - - :param bool update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - - # Figure out our 'real' position - result_position = Vec3( - (self.position[0] / (self.viewport_width / 2)), - (self.position[1] / (self.viewport_height / 2)), - 0 - ) - self._view_matrix = ~(Mat4.from_translation(result_position)) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_combined_matrix(self) -> None: - """ Helper method. This will just pre-compute the combined matrix""" - self._combined_matrix = self._view_matrix @ self._projection_matrix - - def move_to(self, vector: Union[Vec2, tuple], speed: float = 1.0) -> None: - """ - Sets the goal position of the camera. - - The camera will lerp towards this position based on the provided speed, - updating its position every time the use() function is called. - - :param Vec2 vector: Vector to move the camera towards. - :param Vec2 speed: How fast to move the camera, 1.0 is instant, 0.1 moves slowly - """ - self.goal_position = Vec2(*vector) - self.move_speed = speed - self.moving = True - - def move(self, vector: Union[Vec2, tuple]) -> None: - """ - Moves the camera with a speed of 1.0, aka instant move - - This is equivalent to calling move_to(my_pos, 1.0) - """ - self.move_to(vector, 1.0) - - def center(self, vector: Union[Vec2, tuple], speed: float = 1.0) -> None: - """ - Centers the camera on coordinates - """ - if not isinstance(vector, Vec2): - vector2: Vec2 = Vec2(*vector) - else: - vector2 = vector - - # get the center of the camera viewport - center = Vec2(self.viewport_width, self.viewport_height) / 2 - - # adjust vector to projection ratio - vector2 = Vec2(vector2.x * self.viewport_to_projection_width_ratio, - vector2.y * self.viewport_to_projection_height_ratio) - - # move to the vector subtracting the center - target = (vector2 - center) - - self.move_to(target, speed) - - def get_map_coordinates(self, camera_vector: Union[Vec2, tuple]) -> Vec2: - """ - Returns map coordinates in pixels from screen coordinates based on the camera position - - :param Vec2 camera_vector: Vector captured from the camera viewport - """ - return Vec2(*self.position) + Vec2(*camera_vector) - - def resize(self, viewport_width: int, viewport_height: int, *, - resize_projection: bool = True) -> None: - """ - Resize the camera's viewport. Call this when the window resizes. - - :param int viewport_width: Width of the viewport - :param int viewport_height: Height of the viewport - :param bool resize_projection: if True the projection will also be resized - """ - new_viewport = (self._viewport[0], self._viewport[1], viewport_width, viewport_height) - self.set_viewport(new_viewport) - if resize_projection: - self.projection = (self._projection[0], viewport_width, - self._projection[2], viewport_height) - - def update(self): - """ - Update the camera's viewport to the current settings. - """ - if self.moving: - # Apply Goal Position - self.position = self.position.lerp(self.goal_position, self.move_speed) - if self.position == self.goal_position: - self.moving = False - self._set_view_matrix() # this will also set the combined matrix - - def use(self) -> None: - """ - Select this camera for use. Do this right before you draw. - """ - self._window.current_camera = self - - # update camera position and calculate matrix values if needed - self.update() - - # set Viewport / projection - self._window.ctx.viewport = self._viewport # sets viewport of the camera - self._window.projection = self._combined_matrix # sets projection position and zoom - self._window.view = Mat4() # Set to identity matrix for now - - -class Camera(SimpleCamera): - """ - The Camera class is used for controlling the visible viewport, the projection, zoom and rotation. - It is very useful for separating a scrolling screen of sprites, and a GUI overlay. - For an example of this in action, see :ref:`sprite_move_scrolling`. - - :param tuple viewport: (left, bottom, width, height) size of the viewport. If None the window size will be used. - :param tuple projection: (left, right, bottom, top) size of the projection. If None the window size will be used. - :param float zoom: the zoom to apply to the projection - :param float rotation: the angle in degrees to rotate the projection - :param tuple anchor: the x, y point where the camera rotation will anchor. Default is the center of the viewport. - :param Window window: Window to associate with this camera, if working with a multi-window program. - """ - def __init__( - self, *, - viewport: Optional[FourIntTuple] = None, - projection: Optional[FourFloatTuple] = None, - zoom: float = 1.0, - rotation: float = 0.0, - anchor: Optional[Tuple[float, float]] = None, - window: Optional["arcade.Window"] = None, - ): - # scale and zoom - # zoom it's just x scale value. Setting zoom will set scale x, y to the same value - self._scale: Tuple[float, float] = (zoom, zoom) - - # Near and Far - self._near: int = -1 - self._far: int = 1 - - # Shake - self.shake_velocity: Vec2 = Vec2() - self.shake_offset: Vec2 = Vec2() - self.shake_speed: float = 0.0 - self.shake_damping: float = 0.0 - self.shaking: bool = False - - # Call init from superclass here, previous attributes are needed before this call - super().__init__(viewport=viewport, projection=projection, window=window) - - # Rotation - self._rotation: float = rotation # in degrees - self._anchor: Optional[Tuple[float, float]] = anchor # (x, y) to anchor the camera rotation - - # Matrixes - # Rotation matrix holds the matrix used to compute the - # rotation set in window.ctx.view_matrix_2d - self._rotation_matrix: Mat4 = Mat4() - - # Init matrixes - # This will pre-compute the rotation matrix - self._set_rotation_matrix() - - def set_viewport(self, viewport: FourIntTuple) -> None: - """ Sets the viewport """ - super().set_viewport(viewport) - - # the viewport affects the rotation matrix if the rotation anchor is not set - if self._anchor is None: - self._set_rotation_matrix() - - def _set_projection_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the projection and combined matrix - - :param bool update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - # apply zoom - left, right, bottom, top = self._projection - if self._scale != (1.0, 1.0): - right *= self._scale[0] # x axis scale - top *= self._scale[1] # y axis scale - - self._projection_matrix = Mat4.orthogonal_projection(left, right, bottom, top, self._near, - self._far) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_view_matrix(self, *, update_combined_matrix: bool = True) -> None: - """ - Helper method. This will just pre-compute the view and combined matrix - :param bool update_combined_matrix: if True will also update the combined matrix (projection @ view) - """ - - # Figure out our 'real' position plus the shake - result_position = self.position + self.shake_offset - result_position = Vec3( - (result_position[0] / ((self.viewport_width * self._scale[0]) / 2)), - (result_position[1] / ((self.viewport_height * self._scale[1]) / 2)), - 0 - ) - self._view_matrix = ~(Mat4.from_translation(result_position) @ Mat4().scale( - Vec3(self._scale[0], self._scale[1], 1.0))) - if update_combined_matrix: - self._set_combined_matrix() - - def _set_rotation_matrix(self) -> None: - """ Helper method that computes the rotation_matrix every time is needed """ - rotate = Mat4.from_rotation(math.radians(self._rotation), Vec3(0, 0, 1)) - - # If no anchor is set, use the center of the screen - if self._anchor is None: - offset = Vec3(self.position.x, self.position.y, 0) - offset += Vec3(self.viewport_width / 2, self.viewport_height / 2, 0) - else: - offset = Vec3(self._anchor[0], self._anchor[1], 0) - - translate_pre = Mat4.from_translation(offset) - translate_post = Mat4.from_translation(-offset) - - self._rotation_matrix = translate_post @ rotate @ translate_pre - - @property - def scale(self) -> Tuple[float, float]: - """ - Returns the x, y scale. - """ - return self._scale - - @scale.setter - def scale(self, new_scale: Tuple[float, float]) -> None: - """ - Sets the x, y scale (zoom property just sets scale to the same value). - This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - if new_scale[0] <= 0 or new_scale[1] <= 0: - raise ValueError("Scale must be greater than zero") - - self._scale = (float(new_scale[0]), float(new_scale[1])) - - # Changing the scale (zoom) affects both projection_matrix and view_matrix - self._set_projection_matrix( - update_combined_matrix=False) # combined matrix will be set in the next call - self._set_view_matrix() - - @property - def zoom(self) -> float: - """ The zoom applied to the projection. Just returns the x scale value. """ - return self._scale[0] - - @zoom.setter - def zoom(self, zoom: float) -> None: - """ Apply a zoom to the projection """ - self.scale = zoom, zoom - - @property - def near(self) -> int: - """ The near applied to the projection""" - return self._near - - @near.setter - def near(self, near: int) -> None: - """ - Update the near of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._near = near - self._set_projection_matrix() - - @property - def far(self) -> int: - """ The far applied to the projection""" - return self._far - - @far.setter - def far(self, far: int) -> None: - """ - Update the far of the camera. This also updates the projection matrix with an orthogonal - projection based on the projection size of the camera and the zoom applied. - """ - self._far = far - self._set_projection_matrix() - - @property - def rotation(self) -> float: - """ - Get or set the rotation in degrees. - - This will rotate the camera clockwise meaning - the contents will rotate counter-clockwise. - """ - return self._rotation - - @rotation.setter - def rotation(self, value: float) -> None: - self._rotation = value - self._set_rotation_matrix() - - @property - def anchor(self) -> Optional[Tuple[float, float]]: - """ - Get or set the rotation anchor for the camera. - - By default, the anchor is the center of the screen - and the anchor value is `None`. Assigning a custom - anchor point will override this behavior. - The anchor point is in world / global coordinates. - - Example:: - - # Set the anchor to the center of the world - camera.anchor = 0, 0 - # Set the anchor to the center of the player - camera.anchor = player.position - """ - return self._anchor - - @anchor.setter - def anchor(self, anchor: Optional[Tuple[float, float]]) -> None: - if anchor is None: - self._anchor = None - else: - self._anchor = anchor[0], anchor[1] - self._set_rotation_matrix() - - def update(self) -> None: - """ - Update the camera's viewport to the current settings. - """ - update_view_matrix = False - - if self.moving: - # Apply Goal Position - self.position = self.position.lerp(self.goal_position, self.move_speed) - if self.position == self.goal_position: - self.moving = False - update_view_matrix = True - - if self.shaking: - # Apply Camera Shake - - # Move our offset based on shake velocity - self.shake_offset += self.shake_velocity - - # Get x and ys - vx = self.shake_velocity[0] - vy = self.shake_velocity[1] - - ox = self.shake_offset[0] - oy = self.shake_offset[1] - - # Calculate the angle our offset is at, and how far out - angle = math.atan2(ox, oy) - distance = get_distance(0, 0, ox, oy) - velocity_mag = get_distance(0, 0, vx, vy) - - # Ok, what's the reverse? Pull it back in. - reverse_speed = min(self.shake_speed, distance) - opposite_angle = angle + math.pi - opposite_vector = Vec2( - math.sin(opposite_angle) * reverse_speed, - math.cos(opposite_angle) * reverse_speed, - ) - - # Shaking almost done? Zero it out - if velocity_mag < self.shake_speed and distance < self.shake_speed: - self.shake_velocity = Vec2(0, 0) - self.shake_offset = Vec2(0, 0) - self.shaking = False - - # Come up with a new velocity, pulled by opposite vector and damped - self.shake_velocity += opposite_vector - self.shake_velocity *= self.shake_damping - - update_view_matrix = True - - if update_view_matrix: - self._set_view_matrix() # this will also set the combined matrix - - def shake(self, velocity: Union[Vec2, tuple], speed: float = 1.5, damping: float = 0.9) -> None: - """ - Add a camera shake. - - :param Vec2 velocity: Vector to start moving the camera - :param float speed: How fast to shake - :param float damping: How fast to stop shaking - """ - if not isinstance(velocity, Vec2): - velocity = Vec2(*velocity) - - self.shake_velocity += velocity - self.shake_speed = speed - self.shake_damping = damping - self.shaking = True - - def use(self) -> None: - """ - Select this camera for use. Do this right before you draw. - """ - super().use() # call SimpleCamera.use() - - # set rotation matrix - self._window.ctx.view_matrix_2d = self._rotation_matrix # sets rotation and rotation anchor - - def get_sprites_at_point(self, point: "Point", sprite_list: "SpriteList") -> List["Sprite"]: - """ - Get a list of sprites at a particular point when - This function sees if any sprite overlaps - the specified point. If a sprite has a different center_x/center_y but touches the point, - this will return that sprite. - - :param Point point: Point to check - :param SpriteList sprite_list: SpriteList to check against - - :returns: List of sprites colliding, or an empty list. - :rtype: list - """ - raise NotImplementedError() diff --git a/arcade/camera/__init__.py b/arcade/camera/__init__.py new file mode 100644 index 0000000000..8563d228a1 --- /dev/null +++ b/arcade/camera/__init__.py @@ -0,0 +1,26 @@ +""" +The Cinematic Types, Classes, and Methods of Arcade. +Providing a multitude of camera's for any need. +""" + +from arcade.camera.data import CameraData, OrthographicProjectionData, PerspectiveProjectionData +from arcade.camera.types import Projection, Projector, Camera + +from arcade.camera.orthographic import OrthographicProjector +from arcade.camera.perspective import PerspectiveProjector + +from arcade.camera.simple_camera import SimpleCamera +from arcade.camera.camera_2d import Camera2D + +__all__ = [ + 'Projection', + 'Projector', + 'Camera', + 'CameraData', + 'OrthographicProjectionData', + 'OrthographicProjector', + 'PerspectiveProjectionData', + 'PerspectiveProjector', + 'SimpleCamera', + 'Camera2D' +] diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py new file mode 100644 index 0000000000..963dab65be --- /dev/null +++ b/arcade/camera/camera_2d.py @@ -0,0 +1,831 @@ +from typing import TYPE_CHECKING, Optional, Tuple, Iterator +from math import degrees, radians, atan2, cos, sin +from contextlib import contextmanager + +from arcade.camera.data import CameraData, OrthographicProjectionData +from arcade.camera.orthographic import OrthographicProjector +from arcade.camera.types import Projector + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade.application import Window + + +__all__ = [ + 'Camera2D' +] + + +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. + + 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 4 sets of left, right, bottom, top: + - Positional in world space. + - Projection without zoom scaling. + - Projection with zoom scaling. + - Viewport. + + NOTE Once initialised, the CameraData and OrthographicProjectionData SHOULD NOT be changed. + Only getter methods are provided through data and projection_data respectively. + + + :param Window window: The Arcade Window instance that you want to bind the camera to. Uses current if undefined. + :param tuple viewport: The pixel area bounds the camera should draw to. (can be provided through camera_data) + :param tuple position: The X and Y position of the camera. (can be provided through camera_data) + :param tuple up: The up vector which defines the +Y axis in screen space. (can be provided through camera_data) + :param float zoom: A float which scales the viewport. (can be provided through camera_data) + :param tuple projection: The area which will be mapped to screen space. (can be provided through projection_data) + :param float near: The closest Z position before clipping. (can be provided through projection_data) + :param float far: The furthest Z position before clipping. (can be provided through projection_data) + :param CameraData camera_data: A data class which holds all the data needed to define the view of the camera. + :param ProjectionData projection_data: A data class which holds all the data needed to define the projection of + the camera. + """ + # TODO: ADD PARAMS TO DOC FOR __init__ + + def __init__(self, *, + window: Optional["Window"] = None, + viewport: Optional[Tuple[int, int, int, int]] = None, + position: Optional[Tuple[float, float]] = None, + up: Optional[Tuple[float, float]] = None, + zoom: Optional[float] = None, + projection: Optional[Tuple[float, float, float, float]] = None, + near: Optional[float] = None, + far: Optional[float] = None, + camera_data: Optional[CameraData] = None, + projection_data: Optional[OrthographicProjectionData] = None + ): + self._window: "Window" = window or get_window() + print(camera_data, any((viewport, position, up, zoom))) + + assert ( + not any((viewport, position, up, zoom)) and not camera_data + ), ( + "Camera2D Warning: Provided both a CameraData object and raw values. Defaulting to CameraData." + ) + + assert ( + not any((projection, near, far)) and not projection_data + ), ( + "Camera2D Warning: Provided both an OrthographicProjectionData object and raw values." + "Defaulting to OrthographicProjectionData." + ) + + _pos = position or (self._window.width / 2, self._window.height / 2) + _up = up or (0.0, 1.0) + self._data = camera_data or CameraData( + viewport or (0, 0, self._window.width, self._window.height), + (_pos[0], _pos[1], 0.0), + (_up[0], _up[1], 0.0), + (0.0, 0.0, -1.0), + zoom or 1.0 + ) + + _proj = projection or ( + -self._window.width/2, self._window.width/2, + -self._window.height/2, self._window.height/2 + ) + self._projection = projection_data or OrthographicProjectionData( + _proj[0], _proj[1], # Left and Right. + _proj[2], _proj[3], # Bottom and Top. + near or 0.0, far or 100.0 # Near and Far. + ) + + self._ortho_projector: OrthographicProjector = OrthographicProjector( + window=self._window, + view=self._data, + projection=self._projection + ) + + @property + def data(self) -> CameraData: + """ + Return the view data for the camera. This includes the + viewport, position, forward vector, up direction, and zoom. + + If you use any of the built-in arcade camera-controllers + or make your own this is the property to access. + """ + # TODO: Do not add setter + return self._data + + @property + def projection_data(self) -> OrthographicProjectionData: + """ + Return 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. + """ + # TODO: Do not add setter + return self._projection + + @property + def pos(self) -> Tuple[float, float]: + """ + The 2D position of the camera along + the X and Y axis. Arcade has the positive + Y direction go towards the top of the screen. + """ + return self._data.position[:2] + + @pos.setter + def pos(self, _pos: Tuple[float, float]) -> None: + """ + Set the X and Y position of the camera. + """ + self._data.position = _pos + self._data.position[2:] + + @property + def left(self) -> float: + """ + The left side of the camera in world space. + Use this to check if a sprite is on screen. + """ + return self._data.position[0] + self._projection.left/self._data.zoom + + @left.setter + def left(self, _left: float) -> None: + """ + Set the left side of the camera. This moves the position of the camera. + To change the left of the projection use projection_left. + """ + self._data.position = (_left - self._projection.left/self._data.zoom,) + self._data.position[1:] + + @property + def right(self) -> float: + """ + The right side of the camera in world space. + Use this to check if a sprite is on screen. + """ + return self._data.position[0] + self._projection.right/self._data.zoom + + @right.setter + def right(self, _right: float) -> None: + """ + Set the right side of the camera. This moves the position of the camera. + To change the right of the projection use projection_right. + """ + self._data.position = (_right - self._projection.right/self._data.zoom,) + self._data.position[1:] + + @property + def bottom(self) -> float: + """ + The bottom side of the camera in world space. + Use this to check if a sprite is on screen. + """ + return self._data.position[1] + self._projection.bottom/self._data.zoom + + @bottom.setter + def bottom(self, _bottom: float) -> None: + """ + Set the bottom side of the camera. This moves the position of the camera. + To change the bottom of the projection use projection_bottom. + """ + self._data.position = ( + self._data.position[0], + _bottom - self._projection.bottom/self._data.zoom, + self._data.position[2] + ) + + @property + def top(self) -> float: + """ + The top side of the camera in world space. + Use this to check if a sprite is on screen. + """ + return self._data.position[1] + self._projection.top/self._data.zoom + + @top.setter + def top(self, _top: float) -> None: + """ + Set the top side of the camera. This moves the position of the camera. + To change the top of the projection use projection_top. + """ + self._data.position = ( + self._data.position[0], + _top - self._projection.top/self._data.zoom, + self._data.position[2] + ) + + @property + def projection(self) -> Tuple[float, float, float, float]: + """ + The left, right, bottom, top values + that maps world space coordinates to pixel positions. + """ + _p = self._projection + return _p.left, _p.right, _p.bottom, _p.top + + @projection.setter + def projection(self, value: Tuple[float, float, float, float]) -> None: + """ + Set the left, right, bottom, top values + that maps world space coordinates to pixel positions. + """ + _p = self._projection + _p.left, _p.right, _p.bottom, _p.top = value + + @property + def projection_width(self) -> float: + """ + The width of the projection from left to right. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_width_scaled instead. + """ + return self._projection.right - self._projection.left + + @projection_width.setter + def projection_width(self, _width: float): + """ + Set the width of the projection from left to right. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_width_scaled instead. + """ + w = self.projection_width + l = self.projection_left / w # Normalised Projection left + r = self.projection_right / w # Normalised Projection Right + + self.projection_left = l * _width + self.projection_right = r * _width + + @property + def projection_width_scaled(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, + use projection_width instead. + """ + return (self._projection.right - self._projection.left) / self._data.zoom + + @projection_width_scaled.setter + def projection_width_scaled(self, _width: float) -> None: + """ + Set 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, + use projection_width instead. + """ + w = self.projection_width * self._data.zoom + l = self.projection_left / w # Normalised Projection left + r = self.projection_right / w # Normalised Projection Right + + self.projection_left = l * _width + self.projection_right = r * _width + + @property + def projection_height(self) -> float: + """ + The height of the projection from bottom to top. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_height_scaled instead. + """ + return self._projection.top - self._projection.bottom + + @projection_height.setter + def projection_height(self, _height: float) -> None: + """ + Set the height of the projection from bottom to top. + This is in world space coordinates not pixel coordinates. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_height_scaled instead. + """ + h = self.projection_height + b = self.projection_bottom / h # Normalised Projection Bottom + t = self.projection_top / h # Normalised Projection Top + + self.projection_bottom = b * _height + self.projection_top = t * _height + + @property + def projection_height_scaled(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, + use projection_height instead. + """ + return (self._projection.top - self._projection.bottom) / self._data.zoom + + @projection_height_scaled.setter + def projection_height_scaled(self, _height: float) -> None: + """ + Set 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, + use projection_height instead. + """ + h = self.projection_height * self._data.zoom + b = self.projection_bottom / h # Normalised Projection Bottom + t = self.projection_top / h # Normalised Projection Top + + self.projection_bottom = b * _height + self.projection_top = t * _height + + @property + def projection_left(self) -> float: + """ + The left edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_left_scaled instead. + """ + return self._projection.left + + @projection_left.setter + def projection_left(self, _left: float) -> None: + """ + Set the left edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_left_scaled instead. + """ + self._projection.left = _left + + @property + def projection_left_scaled(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_left instead. + """ + return self._projection.left / self._data.zoom + + @projection_left_scaled.setter + def projection_left_scaled(self, _left: float) -> None: + """ + 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_left instead. + """ + self._projection.left = _left * self._data.zoom + + @property + def projection_right(self) -> float: + """ + The right edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_right_scaled instead. + """ + return self._projection.right + + @projection_right.setter + def projection_right(self, _right: float) -> None: + """ + Set the right edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_right_scaled instead. + """ + self._projection.right = _right + + @property + def projection_right_scaled(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_right instead. + """ + return self._projection.right / self._data.zoom + + @projection_right_scaled.setter + def projection_right_scaled(self, _right: float) -> None: + """ + Set 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_right instead. + """ + self._projection.right = _right * self._data.zoom + + @property + def projection_bottom(self) -> float: + """ + The bottom edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_bottom_scaled instead. + """ + return self._projection.bottom + + @projection_bottom.setter + def projection_bottom(self, _bottom: float) -> None: + """ + Set the bottom edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_bottom_scaled instead. + """ + self._projection.bottom = _bottom + + @property + def projection_bottom_scaled(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_bottom instead. + """ + return self._projection.bottom / self._data.zoom + + @projection_bottom_scaled.setter + def projection_bottom_scaled(self, _bottom: float) -> None: + """ + Set 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_bottom instead. + """ + self._projection.bottom = _bottom * self._data.zoom + + @property + def projection_top(self) -> float: + """ + The top edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_top_scaled instead. + """ + return self._projection.top + + @projection_top.setter + def projection_top(self, _top: float) -> None: + """ + Set the top edge of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + If this isn't what you want, + use projection_top_scaled instead. + """ + self._projection.top = _top + + @property + def projection_top_scaled(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_top instead. + """ + return self._projection.top / self._data.zoom + + @projection_top_scaled.setter + def projection_top_scaled(self, _top: float) -> None: + """ + Set 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_top instead. + """ + self._projection.top = _top * self._data.zoom + + @property + 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. + """ + return self._projection.near + + @projection_near.setter + def projection_near(self, _near: float) -> None: + """ + Set the near plane of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + """ + self._projection.near = _near + + @property + 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. + """ + return self._projection.far + + @projection_far.setter + def projection_far(self, _far: float) -> None: + """ + Set the far plane of the projection in world space. + This is not adjusted with the camera position. + + NOTE this IS NOT scaled by zoom. + """ + self._projection.far = _far + + @property + def viewport(self) -> Tuple[int, int, int, int]: + """ + The pixel area that will be drawn to while the camera is active. + (left, right, bottom, top) + """ + return self._data.viewport + + @viewport.setter + def viewport(self, _viewport: Tuple[int, int, int, int]) -> None: + """ + Set the pixel area that will be drawn to while the camera is active. + (left, bottom, width, height) + """ + self._data.viewport = _viewport + + @property + def viewport_width(self) -> int: + """ + The width of the viewport. + Defines the number of pixels drawn too horizontally. + """ + return self._data.viewport[2] + + @viewport_width.setter + def viewport_width(self, _width: int) -> None: + """ + Set the width of the viewport. + Defines the number of pixels drawn too horizontally + """ + self._data.viewport = self._data.viewport[:2] + (_width, self._data.viewport[3]) + + @property + def viewport_height(self) -> int: + """ + The height of the viewport. + Defines the number of pixels drawn too vertically. + """ + return self._data.viewport[3] + + @viewport_height.setter + def viewport_height(self, _height: int) -> None: + """ + Set the height of the viewport. + Defines the number of pixels drawn too vertically. + """ + self._data.viewport = self._data.viewport[:3] + (_height,) + + @property + def viewport_left(self) -> int: + """ + The left most pixel drawn to on the X axis. + """ + return self._data.viewport[0] + + @viewport_left.setter + def viewport_left(self, _left: int) -> None: + """ + Set the left most pixel drawn to on the X axis. + """ + self._data.viewport = (_left,) + self._data.viewport[1:] + + @property + def viewport_right(self) -> int: + """ + The right most pixel drawn to on the X axis. + """ + return self._data.viewport[0] + self._data.viewport[2] + + @viewport_right.setter + def viewport_right(self, _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. + """ + self._data.viewport = (_right - self._data.viewport[2],) + self._data.viewport[1:] + + @property + def viewport_bottom(self) -> int: + """ + The bottom most pixel drawn to on the Y axis. + """ + return self._data.viewport[1] + + @viewport_bottom.setter + def viewport_bottom(self, _bottom: int) -> None: + """ + Set the bottom most pixel drawn to on the Y axis. + """ + self._data.viewport = (self._data.viewport[0], _bottom) + self._data.viewport[2:] + + @property + def viewport_top(self) -> int: + """ + The top most pixel drawn to on the Y axis. + """ + return self._data.viewport[1] + self._data.viewport[3] + + @viewport_top.setter + def viewport_top(self, _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. + """ + self._data.viewport = (self._data.viewport[0], _top - self._data.viewport[3]) + self._data.viewport[2:] + + @property + def up(self) -> Tuple[float, float]: + """ + 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 + camera only provides a 2D view. + """ + return self._data.up[:2] + + @up.setter + def up(self, _up: Tuple[float, float]) -> 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 + camera only provides a 2D view. + + NOTE that this is assumed to be normalised. + """ + self._data.up = _up + (0,) + + @property + def angle(self) -> float: + """ + An angle representation of the 2D UP vector. + 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._data.position[0], self._data.position[1])) + + @angle.setter + def angle(self, value: float): + """ + Set the 2D UP vector using an angle. + This starts with 0 degrees as [0, 1] + rotating clock-wise. + """ + _r = radians(value) + # Note that this is flipped as we want 0 degrees to be vert. + self._data.position = (sin(_r), cos(_r), 0.0) + + @property + def zoom(self) -> float: + """ + A scalar value which describes + how much the projection should + be scaled towards from its center. + + A value of 2.0 causes the projection + to be half its original size. + This causes sprites to appear 2.0x larger. + """ + return self._data.zoom + + @zoom.setter + def zoom(self, _zoom: float) -> None: + """ + Set the scalar value which describes + how much the projection should + be scaled towards from its center. + + A value of 2.0 causes the projection + to be half its original size. + This causes sprites to appear 2.0x larger. + """ + self._data.zoom = _zoom + + def equalise(self) -> None: + """ + Forces the projection to match the size of the viewport. + When matching the projection to the viewport the method keeps + the projections center in the same relative place. + """ + + self.projection_width = self.viewport_width + self.projection_height = self.viewport_height + + def match_screen(self, and_projection: bool = True) -> None: + """ + Sets the viewport to the size of the screen. + Should be called when the screen is resized. + + :param and_projection: Also equalises the projection if True. + """ + self.viewport = (0, 0, self._window.width, self._window.height) + + if and_projection: + self.equalise() + + 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. + + If you want to use a 'with' block use activate() instead. + """ + self._ortho_projector.use() + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + Set internal projector as window projector, + and set the projection and view matrix. + + 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 + try: + self.use() + yield self + finally: + previous_projection.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + """ + Take in a pixel coordinate from within + the range of the viewport and returns + the world space coordinates. + + Essentially reverses the effects of the projector. + + :param screen_coordinate: The pixel coordinates to map back to world coordinates. + """ + + return self._ortho_projector.map_coordinate(screen_coordinate) diff --git a/arcade/camera/controllers/__init__.py b/arcade/camera/controllers/__init__.py new file mode 100644 index 0000000000..97b07b51ba --- /dev/null +++ b/arcade/camera/controllers/__init__.py @@ -0,0 +1,14 @@ +from arcade.camera.controllers.simple_controller_functions import ( + simple_follow, + simple_follow_2D, + simple_easing, + simple_easing_2D +) + + +__all__ = [ + 'simple_follow', + 'simple_follow_2D', + 'simple_easing', + 'simple_easing_2D' +] diff --git a/arcade/camera/controllers/input_controllers.py b/arcade/camera/controllers/input_controllers.py new file mode 100644 index 0000000000..e504ddcd71 --- /dev/null +++ b/arcade/camera/controllers/input_controllers.py @@ -0,0 +1,183 @@ +# TODO: Are 2D and 3D versions of a very simple controller +# intended to be used for debugging. +from typing import TYPE_CHECKING, Tuple, Optional +from copy import deepcopy + +from pyglet.math import Vec3 + +from arcade.camera.data import CameraData +from arcade.camera.controllers.simple_controller_functions import rotate_around_forward +from arcade.window_commands import get_window +import arcade.key as KEYS +if TYPE_CHECKING: + from arcade.application import Window + + +class PolledCameraController2D: + MOVE_UP: int = KEYS.W + MOVE_DOWN: int = KEYS.S + + MOVE_RIGHT: int = KEYS.D + MOVE_LEFT: int = KEYS.A + + ROTATE_RIGHT: int = KEYS.E + ROTATE_LEFT: int = KEYS.Q + + ZOOM_IN: int = KEYS.PLUS + ZOOM_OUT: int = KEYS.MINUS + + RESET: int = KEYS.R + SAVE: int = KEYS.U + + TOGGLE_MOUSE_CENTER: int = KEYS.T + + MOVE_SPEED: float = 600.0 + ROTATE_SPEED: float = 60.0 + + def __init__(self, data: CameraData, *, window: Optional["Window"] = None): + self._win: "Window" = window or get_window() + + self._data: CameraData = data + + self._original_data: CameraData = deepcopy(data) + + self._mouse_old_pos: Tuple[float, float] = (0, 0) + + self._testy: float = 0. + + def reset(self): + self._data.viewport = self._original_data.viewport + + self._data.position = self._original_data.position + + self._data.up = self._original_data.up + self._data.forward = self._original_data.forward + self._data.zoom = self._original_data.zoom + + def save(self): + self._original_data = deepcopy(self._data) + + def change_control_data(self, _new: CameraData, _reset_prev: bool = False): + if _reset_prev: + self.reset() + self._data = _new + self._original_data = deepcopy(_new) + + def update(self, dt): + self._testy = ((self._testy - 0.1) + dt) % 2.0 + 0.1 + self._data.zoom = self._testy + + if self._win.keyboard[self.RESET]: + self.reset() + return + + if self._win.keyboard[self.SAVE]: + self.save() + return + + _rot = self._win.keyboard[self.ROTATE_LEFT] - self._win.keyboard[self.ROTATE_RIGHT] + if _rot: + rotate_around_forward(self._data, _rot * dt * self.ROTATE_SPEED) + + _vert = self._win.keyboard[self.MOVE_UP] - self._win.keyboard[self.MOVE_DOWN] + _hor = self._win.keyboard[self.MOVE_RIGHT] - self._win.keyboard[self.MOVE_LEFT] + + if _vert or _hor: + _dir = (_hor / (_vert**2.0 + _hor**2.0)**0.5, _vert / (_vert**2.0 + _hor**2.0)**0.5) + + _up = self._data.up + _right = Vec3(*self._data.forward).cross(Vec3(*self._data.up)) + + _cam_pos = self._data.position + + _cam_pos = ( + _cam_pos[0] + dt * self.MOVE_SPEED * (_right[0] * _dir[0] + _up[0] * _dir[1]), + _cam_pos[1] + dt * self.MOVE_SPEED * (_right[1] * _dir[0] + _up[1] * _dir[1]), + _cam_pos[2] + dt * self.MOVE_SPEED * (_right[2] * _dir[0] + _up[2] * _dir[1]) + ) + self._data.position = _cam_pos + + +class PolledCameraController3D: + MOVE_FORE: int = KEYS.W + MOVE_BACK: int = KEYS.S + + MOVE_RIGHT: int = KEYS.D + MOVE_LEFT: int = KEYS.A + + ROTATE_RIGHT: int = KEYS.E + ROTATE_LEFT: int = KEYS.Q + + ZOOM_IN: int = KEYS.PLUS + ZOOM_OUT: int = KEYS.MINUS + + RESET: int = KEYS.R + SAVE: int = KEYS.U + + MOVE_SPEED: float = 600.0 + ROTATE_SPEED: float = 60.0 + + def __init__(self, data: CameraData, *, window: Optional["Window"] = None, center_mouse: bool = True): + self._win: "Window" = window or get_window() + + self._data: CameraData = data + + self._original_data: CameraData = deepcopy(data) + + self._mouse_old_pos: Tuple[float, float] = (0, 0) + self._center_mouse: bool = center_mouse + + def toggle_center_mouse(self): + self._center_mouse = bool(1 - self._center_mouse) + + def reset(self): + self._data.viewport = self._original_data.viewport + + self._data.position = self._original_data.position + + self._data.up = self._original_data.up + self._data.forward = self._original_data.forward + self._data.zoom = self._original_data.zoom + + def save(self): + self._original_data = deepcopy(self._data) + + def change_control_data(self, _new: CameraData, _reset_prev: bool = False): + if _reset_prev: + self.reset() + self._data = _new + self._original_data = deepcopy(_new) + + def update(self, dt): + + if self._center_mouse: + self._win.set_exclusive_mouse() + + if self._win.keyboard[self.RESET]: + self.reset() + return + + if self._win.keyboard[self.SAVE]: + self.save() + return + + _rot = self._win.keyboard[self.ROTATE_LEFT] - self._win.keyboard[self.ROTATE_RIGHT] + if _rot: + print(self.ROTATE_SPEED) + rotate_around_forward(self._data, _rot * dt * self.ROTATE_SPEED) + + _move = self._win.keyboard[self.MOVE_FORE] - self._win.keyboard[self.MOVE_BACK] + _strafe = self._win.keyboard[self.MOVE_RIGHT] - self._win.keyboard[self.MOVE_LEFT] + + if _strafe or _move: + _for = self._data.forward + _right = Vec3(*self._data.forward).cross(Vec3(*self._data.up)) + + _cam_pos = self._data.position + + _cam_pos = ( + _cam_pos[0] + dt * self.MOVE_SPEED * (_right[0] * _strafe + _for[0] * _move), + _cam_pos[1] + dt * self.MOVE_SPEED * (_right[1] * _strafe + _for[1] * _move), + _cam_pos[2] + dt * self.MOVE_SPEED * (_right[2] * _strafe + _for[2] * _move) + ) + self._data.position = _cam_pos diff --git a/arcade/camera/controllers/isometric_controller.py b/arcade/camera/controllers/isometric_controller.py new file mode 100644 index 0000000000..944fd74561 --- /dev/null +++ b/arcade/camera/controllers/isometric_controller.py @@ -0,0 +1,95 @@ +from typing import Tuple +from math import sin, cos, radians + +from arcade.camera.data import CameraData +from arcade.camera.controllers.simple_controller_functions import quaternion_rotation + + +class IsometricCameraController: + + def __init__(self, camera_data: CameraData, + target: Tuple[float, float, float] = (0.0, 0.0, 0.0), + angle: float = 0.0, + dist: float = 1.0, + pixel_angle: bool = True, + up: Tuple[float, float, float] = (0.0, 0.0, 1.0), + right: Tuple[float, float, float] = (1.0, 0.0, 0.0)): + self._data: CameraData = camera_data + self._target: Tuple[float, float, float] = target + self._angle: float = angle + self._dist: float = dist + + self._pixel_angle: bool = pixel_angle + + self._up: Tuple[float, float, float] = up + self._right: Tuple[float, float, float] = right + + def update_position(self): + # Ref: https://danceswithcode.net/engineeringnotes/quaternions/quaternions.html + _pos_rads = radians(26.565 if self._pixel_angle else 30.0) + _c, _s = cos(_pos_rads), sin(_pos_rads) + p = ( + (_c * self._right[0] + _s * self._up[0]), + (_c * self._right[1] + _s * self._up[1]), + (_c * self._right[2] + _s * self._up[2]) + ) + + _x, _y, _z = quaternion_rotation(self._up, p, self._angle + 45) + self._data.up = self._up + self._data.forward = -_x, -_y, -_z + self._data.position = ( + self._target[0] + self._dist*_x, + self._target[1] + self._dist*_y, + self._target[2] + self._dist*_z + ) + + def toggle_pixel_angle(self): + self._pixel_angle = bool(1 - self._pixel_angle) + + @property + def pixel_angle(self) -> bool: + return self._pixel_angle + + @pixel_angle.setter + def pixel_angle(self, _px: bool) -> None: + self._pixel_angle = _px + + @property + def zoom(self) -> float: + return self._data.zoom + + @zoom.setter + def zoom(self, _zoom: float) -> None: + self._data.zoom = _zoom + + @property + def angle(self): + return self._angle + + @angle.setter + def angle(self, _angle: float) -> None: + self._angle = _angle + + @property + def target(self) -> Tuple[float, float]: + return self._target[:2] + + @target.setter + def target(self, _target: Tuple[float, float]) -> None: + self._target = _target + (self._target[2],) + + @property + def target_height(self) -> float: + return self._target[2] + + @target_height.setter + def target_height(self, _height: float) -> None: + self._target = self._target[:2] + (_height,) + + @property + def target_full(self) -> Tuple[float, float, float]: + return self._target + + @target_full.setter + def target_full(self, _target: Tuple[float, float, float]) -> None: + self._target = _target diff --git a/arcade/camera/controllers/simple_controller_functions.py b/arcade/camera/controllers/simple_controller_functions.py new file mode 100644 index 0000000000..8ca0c38a87 --- /dev/null +++ b/arcade/camera/controllers/simple_controller_functions.py @@ -0,0 +1,131 @@ +from typing import Tuple, Callable +from math import sin, cos, radians + +from arcade.camera.data import CameraData +from arcade.easing import linear +from pyglet.math import Vec3 + +__all__ = [ + 'simple_follow', + 'simple_follow_2D', + 'simple_easing', + 'simple_easing_2D', + 'quaternion_rotation', + 'rotate_around_forward', + 'rotate_around_up', + 'rotate_around_right' +] + + +def quaternion_rotation(_axis: Tuple[float, float, float], + _vector: Tuple[float, float, float], + _angle: float) -> Tuple[float, float, float]: + # Ref: https://danceswithcode.net/engineeringnotes/quaternions/quaternions.html + _rotation_rads = -radians(_angle) + p1, p2, p3 = _vector + _c2, _s2 = cos(_rotation_rads / 2.0), sin(_rotation_rads / 2.0) + + q0, q1, q2, q3 = ( + _c2, + _s2 * _axis[0], + _s2 * _axis[1], + _s2 * _axis[2] + ) + q0_2, q1_2, q2_2, q3_2 = q0 ** 2, q1 ** 2, q2 ** 2, q3 ** 2 + q01, q02, q03, q12, q13, q23 = q0 * q1, q0 * q2, q0 * q3, q1 * q2, q1 * q3, q2 * q3 + + _x = p1 * (q0_2 + q1_2 - q2_2 - q3_2) + 2.0 * (p2 * (q12 - q03) + p3 * (q02 + q13)) + _y = p2 * (q0_2 - q1_2 + q2_2 - q3_2) + 2.0 * (p1 * (q03 + q12) + p3 * (q23 - q01)) + _z = p3 * (q0_2 - q1_2 - q2_2 + q3_2) + 2.0 * (p1 * (q13 - q02) + p2 * (q01 + q23)) + + return _x, _y, _z + + +def rotate_around_forward(data: CameraData, angle: float): + data.up = quaternion_rotation(data.forward, data.up, angle) + + +def rotate_around_up(data: CameraData, angle: float): + data.forward = quaternion_rotation(data.up, data.forward, angle) + + +def rotate_around_right(data: CameraData, angle: float): + _crossed_vec = Vec3(*data.forward).cross(*data.up) + _right: Tuple[float, float, float] = (_crossed_vec.x, _crossed_vec.y, _crossed_vec.z) + data.forward = quaternion_rotation(_right, data.forward, angle) + data.up = quaternion_rotation(_right, data.up, angle) + + +def _interpolate_3D(s: Tuple[float, float, float], e: Tuple[float, float, float], t: float): + s_x, s_y, s_z = s + e_x, e_y, e_z = e + + return s_x + t * (e_x - s_x), s_y + t * (e_y - s_y), s_z + t * (e_z - s_z) + + +# A set of four methods for moving a camera smoothly in a straight line in various different ways. + +def simple_follow(speed: float, target: Tuple[float, float, float], data: CameraData): + """ + A simple method which moves the camera linearly towards the target point. + + :param speed: The percentage the camera should move towards the target. + :param target: The 3D position the camera should move towards in world space. + :param data: The camera data object which stores its position, rotation, and direction. + """ + + data.position = _interpolate_3D(data.position, target, speed) + + +def simple_follow_2D(speed: float, target: Tuple[float, float], data: CameraData): + """ + A 2D version of simple_follow. Moves the camera only along the X and Y axis. + + :param speed: The percentage the camera should move towards the target. + :param target: The 2D position the camera should move towards in world space. + :param data: The camera data object which stores its position, rotation, and direction. + """ + simple_follow(speed, target + (0,), data) + + +def simple_easing(percent: float, + start: Tuple[float, float, float], + target: Tuple[float, float, float], + data: CameraData, func: Callable[[float], float] = linear): + """ + A simple method which moves a camera in a straight line between two provided points. + It uses an easing function to make the motion smoother. You can use the collection of + easing methods found in arcade.easing. + + :param percent: The percentage from 0 to 1 which describes + how far between the two points to place the camera. + :param start: The 3D point which acts as the starting point for the camera motion. + :param target: The 3D point which acts as the final destination for the camera. + :param data: The camera data object which stores its position, rotation, and direction. + :param func: The easing method to use. It takes in a number between 0-1 + and returns a new number in the same range but altered so the + speed does not stay constant. See arcade.easing for examples. + """ + + data.position = _interpolate_3D(start, target, func(percent)) + + +def simple_easing_2D(percent: float, + start: Tuple[float, float], + target: Tuple[float, float], + data: CameraData, func: Callable[[float], float] = linear): + """ + A 2D version of simple_easing. Moves the camera only along the X and Y axis. + + :param percent: The percentage from 0 to 1 which describes + how far between the two points to place the camera. + :param start: The 3D point which acts as the starting point for the camera motion. + :param target: The 3D point which acts as the final destination for the camera. + :param data: The camera data object which stores its position, rotation, and direction. + :param func: The easing method to use. It takes in a number between 0-1 + and returns a new number in the same range but altered so the + speed does not stay constant. See arcade.easing for examples. + """ + + simple_easing(percent, start + (0,), target + (0,), data, func) + diff --git a/arcade/camera/data.py b/arcade/camera/data.py new file mode 100644 index 0000000000..dfaac3f4f6 --- /dev/null +++ b/arcade/camera/data.py @@ -0,0 +1,76 @@ +from typing import Tuple +from dataclasses import dataclass + + +__all__ = [ + 'CameraData', + 'OrthographicProjectionData', + 'PerspectiveProjectionData' +] + + +@dataclass +class CameraData: + """ + A PoD (Packet of Data) which holds the necessary data for a functional camera excluding the projection data. + + :param viewport: The pixel bounds which will be drawn onto. (left, bottom, width, height) + + :param position: A 3D vector which describes where the camera is located. + :param up: A 3D vector which describes which direction is up (+y). + :param forward: a 3D vector which describes which direction is forwards (+z). + :param zoom: A scaler that records the zoom of the camera. While this most often affects the projection matrix + it allows camera controllers access to the zoom functionality + without interacting with the projection data. + """ + # Viewport data + viewport: Tuple[int, int, int, int] + + # View matrix data + position: Tuple[float, float, float] = (0.0, 0.0, 0.0) + up: Tuple[float, float, float] = (0.0, 0.0, 1.0) + forward: Tuple[float, float, float] = (0.0, -1.0, 0.0) + + # Zoom + zoom: float = 1.0 + + +@dataclass +class OrthographicProjectionData: + """ + A PoD (Packet of Data) which holds the necessary data for a functional Orthographic Projection matrix. + + This is by default a Left-handed system. with the X axis going from left to right, The Y axis going from + bottom to top, and the Z axis going from towards the screen to away from the screen. This can be made + right-handed by making the near value greater than the far value. + + :param left: The left most value, which gets mapped to x = -1.0 (anything below this value is not visible). + :param right: The right most value, which gets mapped to x = 1.0 (anything above this value is not visible). + :param bottom: The bottom most value, which gets mapped to y = -1.0 (anything below this value is not visible). + :param top: The top most value, which gets mapped to y = 1.0 (anything above this value is not visible). + :param near: The 'closest' value, which gets mapped to z = -1.0 (anything below this value is not visible). + :param far: The 'furthest' value, Which gets mapped to z = 1.0 (anything above this value is not visible). + """ + left: float + right: float + bottom: float + top: float + near: float + far: float + + +@dataclass +class PerspectiveProjectionData: + """ + A PoD (Packet of Data) which holds the necessary data for a functional Perspective matrix. + + :param aspect: The aspect ratio of the screen (width over height). + :param fov: The field of view in degrees. With the aspect ratio defines + the size of the projection at any given depth. + :param near: The 'closest' value, which gets mapped to z = -1.0 (anything below this value is not visible). + :param far: The 'furthest' value, Which gets mapped to z = 1.0 (anything above this value is not visible). + """ + aspect: float + fov: float + near: float + far: float diff --git a/arcade/camera/default.py b/arcade/camera/default.py new file mode 100644 index 0000000000..81bb38eb28 --- /dev/null +++ b/arcade/camera/default.py @@ -0,0 +1,60 @@ +from typing import Optional, Tuple, Iterator, TYPE_CHECKING +from contextlib import contextmanager + +from pyglet.math import Mat4 + +from arcade.camera.types import Projector +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade.application import Window + +__all__ = [ + 'DefaultProjector' +] + + +# As this class is only supposed to be used internally +# I wanted to place an _ in front, but the linting complains +# about it being a protected class. +class DefaultProjector: + """ + An extremely limited projector which lacks any kind of control. This is only here to act as the default camera + used internally by arcade. There should be no instance where a developer would want to use this class. + """ + # TODO: ADD PARAMS TO DOC FOR __init__ + + def __init__(self, *, window: Optional["Window"] = None): + self._window: "Window" = window or get_window() + + self._viewport: Tuple[int, int, int, int] = self._window.viewport + + self._projection_matrix: Mat4 = Mat4() + + def _generate_projection_matrix(self): + left = self._viewport[0] + right = self._viewport[0] + self._viewport[2] + + bottom = self._viewport[1] + top = self._viewport[1] + self._viewport[3] + + self._projection_matrix = Mat4.orthogonal_projection(left, right, bottom, top, -100, 100) + + def use(self): + if self._viewport != self._window.viewport: + self._viewport = self._window.viewport + self._generate_projection_matrix() + + self._window.view = Mat4() + self._window.projection = self._projection_matrix + + @contextmanager + def activate(self) -> Iterator[Projector]: + previous = self._window.current_camera + try: + self.use() + yield self + finally: + previous.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + return screen_coordinate diff --git a/arcade/camera/offscreen.py b/arcade/camera/offscreen.py new file mode 100644 index 0000000000..181fb38f26 --- /dev/null +++ b/arcade/camera/offscreen.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING, Optional, Union, List, Tuple +from contextlib import contextmanager + +from arcade.window_commands import get_window +from arcade.camera.types import Projector +from arcade.gl import Framebuffer, Texture2D, Geometry +from arcade.gl.geometry import quad_2d_fs +if TYPE_CHECKING: + from arcade.application import Window + + +class OffScreenSpace: + _geometry: Optional[Geometry] = quad_2d_fs() + + def __init__(self, *, + window: Optional["Window"] = None, + size: Optional[Tuple[int, int]] = None, + color_attachments: Optional[Union[Texture2D, List[Texture2D]]] = None, + depth_attachment: Optional[Texture2D] = None): + self._win: "Window" = window or get_window() + tex_size = size or self._win.size + near = self._win.ctx.NEAREST + self._fbo: Framebuffer = self._win.ctx.framebuffer( + color_attachments=color_attachments or self._win.ctx.texture(tex_size, filter=(near, near)), + depth_attachment=depth_attachment or None + ) + + if OffScreenSpace._geometry is None: + OffScreenSpace._geometry = quad_2d_fs() + + def show(self): + self._fbo.color_attachments[0].use(0) + OffScreenSpace._geometry.render(self._win.ctx.utility_textured_quad_program) + + @contextmanager + def activate(self, *, projector: Optional[Projector] = None, show: bool = False, clear: bool = False): + previous = self._win.ctx.active_framebuffer + prev_cam = self._win.current_camera if projector is not None else None + try: + self._fbo.use() + if clear: + self._fbo.clear() + if projector is not None: + projector.use() + yield self._fbo + finally: + previous.use() + if prev_cam is not None: + prev_cam.use() + if show: + self.show() diff --git a/arcade/camera/orthographic.py b/arcade/camera/orthographic.py new file mode 100644 index 0000000000..d8b937281a --- /dev/null +++ b/arcade/camera/orthographic.py @@ -0,0 +1,160 @@ +from typing import Optional, Tuple, Iterator, TYPE_CHECKING +from contextlib import contextmanager + +from pyglet.math import Mat4, Vec3, Vec4 + +from arcade.camera.data import CameraData, OrthographicProjectionData +from arcade.camera.types import Projector + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade import Window + + +__all__ = [ + 'OrthographicProjector', + 'OrthographicProjectionData' +] + + +class OrthographicProjector: + """ + The simplest from of an orthographic camera. + Using ViewData and OrthographicProjectionData PoDs (Pack of Data) + it generates the correct projection and view matrices. It also + provides methods and a context manager for using the matrices in + glsl shaders. + + This class provides no methods for manipulating the PoDs. + + The current implementation will recreate the view and + projection matrices every time the camera is used. + If used every frame or multiple times per frame this may + be inefficient. If you suspect this is causing slowdowns + profile before optimising with a dirty value check. + """ + # TODO: ADD PARAMS TO DOC FOR __init__ + + def __init__(self, *, + window: Optional["Window"] = None, + view: Optional[CameraData] = None, + projection: Optional[OrthographicProjectionData] = None): + self._window: "Window" = window or get_window() + + self._view = view or CameraData( + (0, 0, self._window.width, self._window.height), # Viewport + (self._window.width / 2, self._window.height / 2, 0), # Position + (0.0, 1.0, 0.0), # Up + (0.0, 0.0, -1.0), # Forward + 1.0 # Zoom + ) + + self._projection = projection or OrthographicProjectionData( + -0.5 * self._window.width, 0.5 * self._window.width, # Left, Right + -0.5 * self._window.height, 0.5 * self._window.height, # Bottom, Top + -100, 100, # Near, Far + ) + + @property + def view_data(self) -> CameraData: + return self._view + + @property + def projection_data(self) -> OrthographicProjectionData: + return self._projection + + def _generate_projection_matrix(self) -> Mat4: + """ + Using the OrthographicProjectionData a projection matrix is generated where the size of the + objects is not affected by depth. + + Generally keep the scale value to integers or negative powers of integers (2^-1, 3^-1, 2^-2, etc.) to keep + the pixels uniform in size. Avoid a scale of 0.0. + """ + + # Find the center of the projection values (often 0,0 or the center of the screen) + _projection_center = ( + (self._projection.left + self._projection.right) / 2, + (self._projection.bottom + self._projection.top) / 2 + ) + + # Find half the width of the projection + _projection_half_size = ( + (self._projection.right - self._projection.left) / 2, + (self._projection.top - self._projection.bottom) / 2 + ) + + # Scale the projection by the zoom value. Both the width and the height + # share a zoom value to avoid ugly stretching. + _true_projection = ( + _projection_center[0] - _projection_half_size[0] / self._view.zoom, + _projection_center[0] + _projection_half_size[0] / self._view.zoom, + _projection_center[1] - _projection_half_size[1] / self._view.zoom, + _projection_center[1] + _projection_half_size[1] / self._view.zoom + ) + return Mat4.orthogonal_projection(*_true_projection, self._projection.near, self._projection.far) + + def _generate_view_matrix(self) -> Mat4: + """ + Using the ViewData it generates a view matrix from the pyglet Mat4 look at function + """ + fo = Vec3(*self._view.forward).normalize() # Forward Vector + up = Vec3(*self._view.up).normalize() # Initial Up Vector (Not perfectly aligned to forward vector) + ri = fo.cross(up) # Right Vector + up = ri.cross(fo) # Up Vector + po = Vec3(*self._view.position) + return Mat4(( + ri.x, up.x, -fo.x, 0, + ri.y, up.y, -fo.y, 0, + ri.z, up.z, -fo.z, 0, + -ri.dot(po), -up.dot(po), fo.dot(po), 1 + )) + + def use(self): + """ + Sets the active camera to this object. + Then generates the view and projection matrices. + Finally, the gl context viewport is set, as well as the projection and view matrices. + """ + + self._window.current_camera = self + + _projection = self._generate_projection_matrix() + _view = self._generate_view_matrix() + + self._window.ctx.viewport = self._view.viewport + self._window.projection = _projection + self._window.view = _view + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + A context manager version of Camera2DOrthographic.use() which allows for the use of + `with` blocks. For example, `with camera.activate() as cam: ...`. + """ + previous_projector = self._window.current_camera + try: + self.use() + yield self + finally: + previous_projector.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + """ + Maps a screen position to a pixel position. + """ + # TODO: better doc string + + screen_x = 2.0 * (screen_coordinate[0] - self._view.viewport[0]) / self._view.viewport[2] - 1 + screen_y = 2.0 * (screen_coordinate[1] - self._view.viewport[1]) / self._view.viewport[3] - 1 + + _view = self._generate_view_matrix() + _projection = self._generate_projection_matrix() + + screen_position = Vec4(screen_x, screen_y, 0.0, 1.0) + + _full = ~(_projection @ _view) + + _mapped_position = _full @ screen_position + + return _mapped_position[0], _mapped_position[1] diff --git a/arcade/camera/perspective.py b/arcade/camera/perspective.py new file mode 100644 index 0000000000..5b4265ec04 --- /dev/null +++ b/arcade/camera/perspective.py @@ -0,0 +1,171 @@ +from typing import Optional, Tuple, Iterator, TYPE_CHECKING +from contextlib import contextmanager + +from pyglet.math import Mat4, Vec3, Vec4 + +from arcade.camera.data import CameraData, PerspectiveProjectionData +from arcade.camera.types import Projector + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade import Window + + +__all__ = [ + 'PerspectiveProjector', + 'PerspectiveProjectionData' +] + + +class PerspectiveProjector: + """ + The simplest from of a perspective camera. + Using ViewData and PerspectiveProjectionData PoDs (Pack of Data) + it generates the correct projection and view matrices. It also + provides methods and a context manager for using the matrices in + glsl shaders. + + This class provides no methods for manipulating the PoDs. + + The current implementation will recreate the view and + projection matrices every time the camera is used. + If used every frame or multiple times per frame this may + be inefficient. + """ + # TODO: ADD PARAMS TO DOC FOR __init__ + + def __init__(self, *, + window: Optional["Window"] = None, + view: Optional[CameraData] = None, + projection: Optional[PerspectiveProjectionData] = None): + self._window: "Window" = window or get_window() + + self._view = view or CameraData( + (0, 0, self._window.width, self._window.height), # Viewport + (self._window.width / 2, self._window.height / 2, 0), # Position + (0.0, 1.0, 0.0), # Up + (0.0, 0.0, -1.0), # Forward + 1.0 # Zoom + ) + + self._projection = projection or PerspectiveProjectionData( + self._window.width / self._window.height, # Aspect ratio + 90, # Field of view (degrees) + 0.1, 1000 # Near, Far + ) + + @property + def view_data(self) -> CameraData: + return self._view + + @property + def projection(self) -> PerspectiveProjectionData: + return self._projection + + def _generate_projection_matrix(self) -> Mat4: + """ + Using the PerspectiveProjectionData a projection matrix is generated where the size of the + objects is affected by depth. + + The zoom value shrinks the effective fov of the camera. For example a zoom of two will have the + fov resulting in 2x zoom effect. + """ + + _true_fov = self._projection.fov / self._view.zoom + return Mat4.perspective_projection( + self._projection.aspect, + self._projection.near, + self._projection.far, + _true_fov + ) + + def _generate_view_matrix(self) -> Mat4: + """ + Using the ViewData it generates a view matrix from the pyglet Mat4 look at function + """ + fo = Vec3(*self._view.forward).normalize() # Forward Vector + up = Vec3(*self._view.up).normalize() # Initial Up Vector (Not perfectly aligned to forward vector) + ri = fo.cross(up) # Right Vector + up = ri.cross(fo) # Up Vector + po = Vec3(*self._view.position) + return Mat4(( + ri.x, up.x, -fo.x, 0, + ri.y, up.y, -fo.y, 0, + ri.z, up.z, -fo.z, 0, + -ri.dot(po), -up.dot(po), fo.dot(po), 1 + )) + + def use(self): + """ + Sets the active camera to this object. + Then generates the view and projection matrices. + Finally, the gl context viewport is set, as well as the projection and view matrices. + """ + + self._window.current_camera = self + + _projection = self._generate_projection_matrix() + _view = self._generate_view_matrix() + + self._window.ctx.viewport = self._view.viewport + self._window.projection = _projection + self._window.view = _view + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + A context manager version of Camera2DOrthographic.use() which allows for the use of + `with` blocks. For example, `with camera.activate() as cam: ...`. + + :WARNING: + Currently there is no 'default' camera within arcade. This means this method will raise a value error + as self._window.current_camera is None initially. To solve this issue you only need to make a default + camera and call the use() method. + """ + previous_projector = self._window.current_camera + try: + self.use() + yield self + finally: + previous_projector.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + """ + Maps a screen position to a pixel position at the near clipping plane of the camera. + """ + + screen_x = 2.0 * (screen_coordinate[0] - self._view.viewport[0]) / self._view.viewport[2] - 1 + screen_y = 2.0 * (screen_coordinate[1] - self._view.viewport[1]) / self._view.viewport[3] - 1 + + _view = self._generate_view_matrix() + _projection = self._generate_projection_matrix() + + screen_position = Vec4(screen_x, screen_y, -1.0, 1.0) + + _full = ~(_projection @ _view) + + _mapped_position = _full @ screen_position + + return _mapped_position[0], _mapped_position[1] + + def map_coordinate_at_depth(self, + screen_coordinate: Tuple[float, float], + depth: float) -> Tuple[float, float]: + """ + Maps a screen position to a pixel position at the specific depth supplied. + """ + screen_x = 2.0 * (screen_coordinate[0] - self._view.viewport[0]) / self._view.viewport[2] - 1 + screen_y = 2.0 * (screen_coordinate[1] - self._view.viewport[1]) / self._view.viewport[3] - 1 + + _view = self._generate_view_matrix() + _projection = self._generate_projection_matrix() + + _depth = 2.0 * depth / (self._projection.far - self._projection.near) - 1 + + screen_position = Vec4(screen_x, screen_y, _depth, 1.0) + + _full = ~(_projection @ _view) + + _mapped_position = _full @ screen_position + + return _mapped_position[0], _mapped_position[1] diff --git a/arcade/camera/simple_camera.py b/arcade/camera/simple_camera.py new file mode 100644 index 0000000000..b36da3f5d0 --- /dev/null +++ b/arcade/camera/simple_camera.py @@ -0,0 +1,346 @@ +from typing import TYPE_CHECKING, Optional, Tuple, Iterator +from contextlib import contextmanager +from math import atan2, cos, sin, degrees, radians + +from pyglet.math import Vec3 + +from arcade.camera.data import CameraData, OrthographicProjectionData +from arcade.camera.types import Projector +from arcade.camera.orthographic import OrthographicProjector + +from arcade.window_commands import get_window +if TYPE_CHECKING: + from arcade import Window + + +__all__ = [ + 'SimpleCamera' +] + + +class SimpleCamera: + """ + A simple camera which uses an orthographic camera and a simple 2D Camera Controller. + It also implements an update method that allows for an interpolation between two points + + Written to be backwards compatible with the old SimpleCamera. + """ + # TODO: ADD PARAMS TO DOC FOR __init__ + + def __init__(self, *, + window: Optional["Window"] = None, + viewport: Optional[Tuple[int, int, int, int]] = None, + projection: Optional[Tuple[float, float, float, float]] = None, + position: Optional[Tuple[float, float]] = None, + up: Optional[Tuple[float, float]] = None, + zoom: Optional[float] = None, + near: Optional[float] = None, + far: Optional[float] = None, + camera_data: Optional[CameraData] = None, + projection_data: Optional[OrthographicProjectionData] = None + ): + self._window = window or get_window() + + if any((viewport, projection, position, up, zoom, near, far)) and any((camera_data, projection_data)): + raise ValueError("Provided both data structures and raw values." + "Only supply one or the other") + + if any((viewport, projection, position, up, zoom, near, far)): + _pos = position or (0.0, 0.0) + _up = up or (0.0, 1.0) + self._view = CameraData( + viewport or (0, 0, self._window.width, self._window.height), + (_pos[0], _pos[1], 0.0), + (_up[0], _up[1], 0.0), + (0.0, 0.0, 1.0), + zoom or 1.0 + ) + _projection = projection or ( + 0.0, self._window.width, + 0.0, self._window.height + ) + self._projection = OrthographicProjectionData( + _projection[0] or 0.0, _projection[1] or self._window.hwidth, # Left, Right + _projection[2] or 0.0, _projection[3] or self._window.height, # Bottom, Top + near or -100, far or 100 # Near, Far + ) + else: + self._view = camera_data or CameraData( + (0, 0, self._window.width, self._window.height), # Viewport + (self._window.width / 2, self._window.height / 2, 0.0), # Position + (0, 1.0, 0.0), # Up + (0.0, 0.0, 1.0), # Forward + 1.0 # Zoom + ) + self._projection = projection_data or OrthographicProjectionData( + 0.0, self._window.width, # Left, Right + 0.0, self._window.height, # Bottom, Top + -100, 100 # Near, Far + ) + + self._camera = OrthographicProjector( + window=self._window, + view=self._view, + projection=self._projection + ) + + self._easing_speed: float = 0.0 + self._position_goal: Tuple[float, float] = self.position + + # Basic properties for modifying the viewport and orthographic projection + + @property + def viewport_width(self) -> int: + """ Returns the width of the viewport """ + return self._view.viewport[2] + + @property + def viewport_height(self) -> int: + """ Returns the height of the viewport """ + return self._view.viewport[3] + + @property + def viewport(self) -> Tuple[int, int, int, int]: + """ The pixel area that will be drawn to while this camera is active (left, bottom, width, height) """ + return self._view.viewport + + @viewport.setter + def viewport(self, viewport: Tuple[int, int, int, int]) -> None: + """ Set the viewport (left, bottom, width, height) """ + self.set_viewport(viewport) + + def set_viewport(self, viewport: Tuple[int, int, int, int]) -> None: + self._view.viewport = viewport + + @property + def projection(self) -> Tuple[float, float, float, float]: + """ + The dimensions that will be projected to the viewport. (left, right, bottom, top). + """ + return self._projection.left, self._projection.right, self._projection.bottom, self._projection.top + + @projection.setter + def projection(self, projection: Tuple[float, float, float, float]) -> None: + """ + Update the orthographic projection of the camera. (left, right, bottom, top). + """ + self._projection.left = projection[0] + self._projection.right = projection[1] + self._projection.bottom = projection[2] + self._projection.top = projection[3] + + # Methods for retrieving the viewport - projection ratios. Originally written by Alejandro Casanovas. + @property + def viewport_to_projection_width_ratio(self) -> float: + """ + The ratio of viewport width to projection width. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to more than one unit (Zoom out). + """ + return (self.viewport_width * self.zoom) / (self._projection.left - self._projection.right) + + @property + def viewport_to_projection_height_ratio(self) -> float: + """ + The ratio of viewport height to projection height. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to more than one unit (Zoom out). + """ + return (self.viewport_height * self.zoom) / (self._projection.bottom - self._projection.top) + + @property + def projection_to_viewport_width_ratio(self) -> float: + """ + The ratio of projection width to viewport width. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to less than one unit (Zoom in). + """ + return (self._projection.left - self._projection.right) / (self.zoom * self.viewport_width) + + @property + def projection_to_viewport_height_ratio(self) -> float: + """ + The ratio of projection height to viewport height. + A value of 1.0 represents that an object that moves one unit will move one pixel. + A value less than one means that one pixel is equivalent to less than one unit (Zoom in). + """ + return (self._projection.bottom - self._projection.top) / (self.zoom * self.viewport_height) + + # Control methods (movement, zooming, rotation) + @property + def position(self) -> Tuple[float, float]: + """ + The position of the camera based on the bottom left coordinate. + """ + return self._view.position[0], self._view.position[1] + + @position.setter + def position(self, pos: Tuple[float, float]) -> None: + """ + Set the position of the camera based on the bottom left coordinate. + """ + self._view.position = (pos[0], pos[1], self._view.position[2]) + + @property + def zoom(self) -> float: + """ + A scaler which adjusts the size of the orthographic projection. + A higher zoom value means larger pixels. + For best results keep the zoom value an integer to an integer or an integer to the power of -1. + """ + return self._view.zoom + + @zoom.setter + def zoom(self, zoom: float) -> None: + """ + A scaler which adjusts the size of the orthographic projection. + A higher zoom value means larger pixels. + For best results keep the zoom value an integer to an integer or an integer to the power of -1. + """ + self._view.zoom = zoom + + @property + def up(self) -> Tuple[float, float]: + """ + A 2D normalised vector which defines which direction corresponds to the +Y axis. + """ + return self._view.up[0], self._view.up[1] + + @up.setter + def up(self, up: Tuple[float, float]) -> None: + """ + A 2D normalised vector which defines which direction corresponds to the +Y axis. + generally easier to use the `rotate` and `rotate_to` methods as they use an angle value. + """ + _up = Vec3(up[0], up[1], 0.0).normalize() + self._view.up = (_up[0], _up[1], _up[2]) + + @property + def angle(self) -> float: + """ + An alternative way of setting the up vector of the camera. + The angle value goes clock-wise starting from (0.0, 1.0). + """ + return degrees(atan2(self.up[0], self.up[1])) + + @angle.setter + def angle(self, angle: float) -> None: + """ + An alternative way of setting the up vector of the camera. + The angle value goes clock-wise starting from (0.0, 1.0). + """ + rad = radians(angle) + self.up = ( + sin(rad), + cos(rad) + ) + + def move_to(self, vector: Tuple[float, float], speed: float = 1.0) -> None: + """ + Sets the goal position of the camera. + + The camera will lerp towards this position based on the provided speed, + updating its position every time the use() function is called. + + :param Vec2 vector: Vector to move the camera towards. + :param Vec2 speed: How fast to move the camera, 1.0 is instant, 0.1 moves slowly + """ + self._position_goal = vector + self._easing_speed = speed + + def move(self, vector: Tuple[float, float]) -> None: + """ + Moves the camera with a speed of 1.0, aka instant move + + This is equivalent to calling move_to(my_pos, 1.0) + """ + self.move_to(vector, 1.0) + + def center(self, vector: Tuple[float, float], speed: float = 1.0) -> None: + """ + Centers the camera. Allows for a linear lerp like the move_to() method. + """ + viewport_center = self.viewport_width / 2, self.viewport_height / 2 + + adjusted_vector = ( + vector[0] * self.viewport_to_projection_width_ratio, + vector[1] * self.viewport_to_projection_height_ratio + ) + + target = ( + adjusted_vector[0] - viewport_center[0], + adjusted_vector[1] - viewport_center[1] + ) + + self.move_to(target, speed) + + # General Methods + + def update(self): + """ + Update the camera's position. + """ + if self._easing_speed > 0.0: + x_a = self.position[0] + x_b = self._position_goal[0] + + y_a = self.position[1] + y_b = self._position_goal[1] + + self.position = ( + x_a + (x_b - x_a) * self._easing_speed, # Linear Lerp X position + y_a + (y_b - y_a) * self._easing_speed # Linear Lerp Y position + ) + if self.position == self._position_goal: + self._easing_speed = 0.0 + + def use(self): + """ + Sets the active camera to this object. + Then generates the view and projection matrices. + Finally, the gl context viewport is set, as well as the projection and view matrices. + This method also calls the update method. This can cause the camera to move faster than expected + if the camera is used multiple times in a single frame. + """ + + # Updated the position + self.update() + + # set matrices + self._camera.use() + + @contextmanager + def activate(self) -> Iterator[Projector]: + """ + A context manager version of Camera2DOrthographic.use() which allows for the use of + `with` blocks. For example, `with camera.activate() as cam: ...`. + """ + previous_projector = self._window.current_camera + try: + self.use() + yield self + finally: + previous_projector.use() + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + """ + Maps a screen position to a pixel position. + """ + # TODO: better doc string + + return self._camera.map_coordinate(screen_coordinate) + + def resize(self, viewport_width: int, viewport_height: int, *, + resize_projection: bool = True) -> None: + """ + Resize the camera's viewport. Call this when the window resizes. + + :param int viewport_width: Width of the viewport + :param int viewport_height: Height of the viewport + :param bool resize_projection: if True the projection will also be resized + """ + new_viewport = (self.viewport[0], self.viewport[1], viewport_width, viewport_height) + self.set_viewport(new_viewport) + if resize_projection: + self.projection = (self._projection.left, viewport_width, + self._projection.bottom, viewport_height) diff --git a/arcade/camera/types.py b/arcade/camera/types.py new file mode 100644 index 0000000000..b32600463b --- /dev/null +++ b/arcade/camera/types.py @@ -0,0 +1,35 @@ +from typing import Protocol, Tuple, Iterator +from contextlib import contextmanager + +from arcade.camera.data import CameraData + + +__all__ = [ + 'Projection', + 'Projector', + 'Camera' +] + + +class Projection(Protocol): + near: float + far: float + + +class Projector(Protocol): + + def use(self) -> None: + ... + + @contextmanager + def activate(self) -> Iterator["Projector"]: + ... + + def map_coordinate(self, screen_coordinate: Tuple[float, float]) -> Tuple[float, float]: + ... + + +class Camera(Protocol): + _view: CameraData + _projection: Projection + diff --git a/arcade/context.py b/arcade/context.py index 9bb3d7fbcb..7770150b98 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -132,6 +132,14 @@ def __init__(self, window: pyglet.window.Window, gc_mode: str = "context_gc", gl self.collision_buffer = self.buffer(reserve=1024 * 4) self.collision_query = self.query(samples=False, time=False, primitives=True) + # General Utility + + # renders a quad (without projection) with a single 4-component texture. + self.utility_textured_quad_program: Program = self.load_program( + vertex_shader=":system:shaders/util/textured_quad_vs.glsl", + fragment_shader=":system:shaders/collision/textured_quad_fs.glsl", + ) + # --- Pre-created geometry and buffers for unbuffered draw calls ---- # FIXME: These pre-created resources needs to be packaged nicely # Just having them globally in the context is probably not a good idea diff --git a/arcade/examples/background_blending.py b/arcade/examples/background_blending.py index b1a8048edf..8e2820c29f 100644 --- a/arcade/examples/background_blending.py +++ b/arcade/examples/background_blending.py @@ -23,7 +23,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() # Load the first background from file. Sized to match the screen self.background_1 = background.Background.from_file( diff --git a/arcade/examples/background_groups.py b/arcade/examples/background_groups.py index caddaf0884..9e1c7d714d 100644 --- a/arcade/examples/background_groups.py +++ b/arcade/examples/background_groups.py @@ -28,7 +28,7 @@ def __init__(self): # Set the background color to equal to that of the first background. self.background_color = (5, 44, 70) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() # create a background group which will hold all the backgrounds. self.backgrounds = background.BackgroundGroup() diff --git a/arcade/examples/background_parallax.py b/arcade/examples/background_parallax.py index 0ab9243ea7..878cac944f 100644 --- a/arcade/examples/background_parallax.py +++ b/arcade/examples/background_parallax.py @@ -38,7 +38,7 @@ def __init__(self): # Set the background color to match the sky in the background images self.background_color = (162, 84, 162, 255) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() # Create a background group to hold all the landscape's layers self.backgrounds = background.ParallaxGroup() diff --git a/arcade/examples/background_scrolling.py b/arcade/examples/background_scrolling.py index 4ba542559a..7f22ed06f2 100644 --- a/arcade/examples/background_scrolling.py +++ b/arcade/examples/background_scrolling.py @@ -23,7 +23,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() # Load the background from file. Sized to match the screen self.background = background.Background.from_file( diff --git a/arcade/examples/background_stationary.py b/arcade/examples/background_stationary.py index 2f6e0a9cba..cc52359a98 100644 --- a/arcade/examples/background_stationary.py +++ b/arcade/examples/background_stationary.py @@ -22,7 +22,7 @@ class MyGame(arcade.Window): def __init__(self): super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE, resizable=True) - self.camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() # Load the background from file. It defaults to the size of the texture with the bottom left corner at (0, 0). # Image from: diff --git a/arcade/examples/camera_platform.py b/arcade/examples/camera_platform.py index 3fe65e609e..16016a8a5a 100644 --- a/arcade/examples/camera_platform.py +++ b/arcade/examples/camera_platform.py @@ -130,8 +130,8 @@ def setup(self): self.scene.add_sprite("Player", self.player_sprite) viewport = (0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) - self.camera = arcade.Camera(viewport=viewport) - self.gui_camera = arcade.Camera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Center camera on user self.pan_camera_to_user() @@ -255,7 +255,7 @@ def on_update(self, delta_time): for bomb in bombs_hit: bomb.remove_from_sprite_lists() print("Pow") - self.camera.shake((4, 7)) + # TODO: self.camera.shake((4, 7)) -> Camera Missing This Functionality # Pan to the user self.pan_camera_to_user(panning_fraction=0.12) diff --git a/arcade/examples/minimap.py b/arcade/examples/minimap.py index 6478f6526a..6add0430ea 100644 --- a/arcade/examples/minimap.py +++ b/arcade/examples/minimap.py @@ -63,8 +63,8 @@ def __init__(self, width, height, title): # Camera for sprites, and one for our GUI viewport = (0, 0, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) - self.camera_sprites = arcade.SimpleCamera(viewport=viewport) - self.camera_gui = arcade.SimpleCamera(viewport=viewport) + self.camera_sprites = arcade.camera.SimpleCamera(viewport=viewport) + self.camera_gui = arcade.camera.SimpleCamera(viewport=viewport) def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/minimap_camera.py b/arcade/examples/minimap_camera.py index 229bcd3739..ad7575886f 100644 --- a/arcade/examples/minimap_camera.py +++ b/arcade/examples/minimap_camera.py @@ -56,7 +56,7 @@ def __init__(self, width, height, title): DEFAULT_SCREEN_HEIGHT - MINIMAP_HEIGHT, MINIMAP_WIDTH, MINIMAP_HEIGHT) minimap_projection = (0, MAP_PROJECTION_WIDTH, 0, MAP_PROJECTION_HEIGHT) - self.camera_minimap = arcade.Camera(viewport=minimap_viewport, projection=minimap_projection) + self.camera_minimap = arcade.camera.SimpleCamera(viewport=minimap_viewport, projection=minimap_projection) # Set up the player self.player_sprite = None @@ -66,8 +66,8 @@ def __init__(self, width, height, title): # Camera for sprites, and one for our GUI viewport = (0, 0, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT) projection = (0, DEFAULT_SCREEN_WIDTH, 0, DEFAULT_SCREEN_HEIGHT) - self.camera_sprites = arcade.Camera(viewport=viewport, projection=projection) - self.camera_gui = arcade.SimpleCamera(viewport=viewport) + self.camera_sprites = arcade.camera.SimpleCamera(viewport=viewport, projection=projection) + self.camera_gui = arcade.camera.SimpleCamera(viewport=viewport) self.selected_camera = self.camera_minimap diff --git a/arcade/examples/platform_tutorial/06_camera.py b/arcade/examples/platform_tutorial/06_camera.py index 047d38c88d..38b7526308 100644 --- a/arcade/examples/platform_tutorial/06_camera.py +++ b/arcade/examples/platform_tutorial/06_camera.py @@ -48,7 +48,7 @@ def setup(self): """Set up the game here. Call this function to restart the game.""" # Set up the Camera - self.camera = arcade.SimpleCamera(viewport=(0, 0, self.width, self.height)) + self.camera = arcade.camera.SimpleCamera(viewport=(0, 0, self.width, self.height)) # Initialize Scene self.scene = arcade.Scene() diff --git a/arcade/examples/platform_tutorial/07_coins_and_sound.py b/arcade/examples/platform_tutorial/07_coins_and_sound.py index 2fa961c626..f0ec91192d 100644 --- a/arcade/examples/platform_tutorial/07_coins_and_sound.py +++ b/arcade/examples/platform_tutorial/07_coins_and_sound.py @@ -53,7 +53,7 @@ def setup(self): """Set up the game here. Call this function to restart the game.""" # Set up the Camera - self.camera = arcade.SimpleCamera(viewport=(0, 0, self.width, self.height)) + self.camera = arcade.camera.SimpleCamera(viewport=(0, 0, self.width, self.height)) # Initialize Scene self.scene = arcade.Scene() diff --git a/arcade/examples/platform_tutorial/08_score.py b/arcade/examples/platform_tutorial/08_score.py index 368321d332..588147037e 100644 --- a/arcade/examples/platform_tutorial/08_score.py +++ b/arcade/examples/platform_tutorial/08_score.py @@ -59,10 +59,10 @@ def setup(self): """Set up the game here. Call this function to restart the game.""" # Set up the Game Camera - self.camera = arcade.SimpleCamera(viewport=(0, 0, self.width, self.height)) + self.camera = arcade.camera.SimpleCamera(viewport=(0, 0, self.width, self.height)) # Set up the GUI Camera - self.gui_camera = arcade.SimpleCamera(viewport=(0, 0, self.width, self.height)) + self.gui_camera = arcade.camera.SimpleCamera(viewport=(0, 0, self.width, self.height)) # Keep track of the score self.score = 0 diff --git a/arcade/examples/platform_tutorial/09_load_map.py b/arcade/examples/platform_tutorial/09_load_map.py index 54b45da449..253a16b4ba 100644 --- a/arcade/examples/platform_tutorial/09_load_map.py +++ b/arcade/examples/platform_tutorial/09_load_map.py @@ -63,8 +63,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Name of map file to load map_name = ":resources:tiled_maps/map.json" diff --git a/arcade/examples/platform_tutorial/10_multiple_levels.py b/arcade/examples/platform_tutorial/10_multiple_levels.py index 1b333d2b0b..eee99e09a1 100644 --- a/arcade/examples/platform_tutorial/10_multiple_levels.py +++ b/arcade/examples/platform_tutorial/10_multiple_levels.py @@ -84,8 +84,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = f":resources:tiled_maps/map2_level_{self.level}.json" diff --git a/arcade/examples/platform_tutorial/11_ladders_and_more.py b/arcade/examples/platform_tutorial/11_ladders_and_more.py index e82c9b5810..370989ed49 100644 --- a/arcade/examples/platform_tutorial/11_ladders_and_more.py +++ b/arcade/examples/platform_tutorial/11_ladders_and_more.py @@ -78,8 +78,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/12_animate_character.py b/arcade/examples/platform_tutorial/12_animate_character.py index 08b523ae24..e9a7567927 100644 --- a/arcade/examples/platform_tutorial/12_animate_character.py +++ b/arcade/examples/platform_tutorial/12_animate_character.py @@ -199,8 +199,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/13_add_enemies.py b/arcade/examples/platform_tutorial/13_add_enemies.py index f4952157f3..7b5783551b 100644 --- a/arcade/examples/platform_tutorial/13_add_enemies.py +++ b/arcade/examples/platform_tutorial/13_add_enemies.py @@ -220,8 +220,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/14_moving_enemies.py b/arcade/examples/platform_tutorial/14_moving_enemies.py index 3312bc15f5..3aa3362894 100644 --- a/arcade/examples/platform_tutorial/14_moving_enemies.py +++ b/arcade/examples/platform_tutorial/14_moving_enemies.py @@ -251,8 +251,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/15_collision_with_enemies.py b/arcade/examples/platform_tutorial/15_collision_with_enemies.py index 5eea8deaff..9185a29156 100644 --- a/arcade/examples/platform_tutorial/15_collision_with_enemies.py +++ b/arcade/examples/platform_tutorial/15_collision_with_enemies.py @@ -251,8 +251,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/16_shooting_bullets.py b/arcade/examples/platform_tutorial/16_shooting_bullets.py index 1caab59d56..1c226b8221 100644 --- a/arcade/examples/platform_tutorial/16_shooting_bullets.py +++ b/arcade/examples/platform_tutorial/16_shooting_bullets.py @@ -270,8 +270,8 @@ def setup(self): # Setup the Cameras viewport = (0, 0, self.width, self.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/platform_tutorial/17_views.py b/arcade/examples/platform_tutorial/17_views.py index bb83827e4f..ef3b82c5b0 100644 --- a/arcade/examples/platform_tutorial/17_views.py +++ b/arcade/examples/platform_tutorial/17_views.py @@ -292,8 +292,8 @@ def setup(self): # Set up the Cameras viewport = (0, 0, self.window.width, self.window.height) - self.camera = arcade.SimpleCamera(viewport=viewport) - self.gui_camera = arcade.SimpleCamera(viewport=viewport) + self.camera = arcade.camera.SimpleCamera(viewport=viewport) + self.gui_camera = arcade.camera.SimpleCamera(viewport=viewport) # Map name map_name = ":resources:tiled_maps/map_with_ladders.json" diff --git a/arcade/examples/procedural_caves_cellular.py b/arcade/examples/procedural_caves_cellular.py index dcdaa4793a..5150bc2b21 100644 --- a/arcade/examples/procedural_caves_cellular.py +++ b/arcade/examples/procedural_caves_cellular.py @@ -155,8 +155,8 @@ def __init__(self): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.SimpleCamera() self.window.background_color = arcade.color.BLACK diff --git a/arcade/examples/sprite_move_scrolling.py b/arcade/examples/sprite_move_scrolling.py index 4f661e6c18..3203d4ee89 100644 --- a/arcade/examples/sprite_move_scrolling.py +++ b/arcade/examples/sprite_move_scrolling.py @@ -55,8 +55,8 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.SimpleCamera() def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/sprite_move_scrolling_box.py b/arcade/examples/sprite_move_scrolling_box.py index 1baf848ad6..4dfddbfe81 100644 --- a/arcade/examples/sprite_move_scrolling_box.py +++ b/arcade/examples/sprite_move_scrolling_box.py @@ -55,8 +55,8 @@ def __init__(self, width, height, title): self.up_pressed = False self.down_pressed = False - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.SimpleCamera() def setup(self): """ Set up the game and initialize the variables. """ diff --git a/arcade/examples/sprite_move_scrolling_shake.py b/arcade/examples/sprite_move_scrolling_shake.py index d1778fd2d9..2dd46b150a 100644 --- a/arcade/examples/sprite_move_scrolling_shake.py +++ b/arcade/examples/sprite_move_scrolling_shake.py @@ -8,7 +8,7 @@ """ import random -import math +# import math import arcade SPRITE_SCALING = 0.5 @@ -54,8 +54,8 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.Camera() - self.camera_gui = arcade.Camera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.Camera() self.explosion_sound = arcade.load_sound(":resources:sounds/explosion1.wav") @@ -153,22 +153,23 @@ def on_update(self, delta_time): # --- Shake the camera --- # Pick a random direction - shake_direction = random.random() * 2 * math.pi + # shake_direction = random.random() * 2 * math.pi # How 'far' to shake - shake_amplitude = 10 + # shake_amplitude = 10 # Calculate a vector based on that - shake_vector = ( - math.cos(shake_direction) * shake_amplitude, - math.sin(shake_direction) * shake_amplitude - ) + # shake_vector = ( + # math.cos(shake_direction) * shake_amplitude, + # math.sin(shake_direction) * shake_amplitude + # ) # Frequency of the shake - shake_speed = 1.5 + # shake_speed = 1.5 # How fast to damp the shake - shake_damping = 0.9 + # shake_damping = 0.9 # Do the shake - self.camera_sprites.shake(shake_vector, - speed=shake_speed, - damping=shake_damping) + # TODO: Camera missing shake. + # self.camera_sprites.shake(shake_vector, + # speed=shake_speed, + # damping=shake_damping) def scroll_to_player(self): """ diff --git a/arcade/examples/sprite_moving_platforms.py b/arcade/examples/sprite_moving_platforms.py index 11606f8f64..623031c47a 100644 --- a/arcade/examples/sprite_moving_platforms.py +++ b/arcade/examples/sprite_moving_platforms.py @@ -55,8 +55,8 @@ def __init__(self, width, height, title): # Create the cameras. One for the GUI, one for the sprites. # We scroll the 'sprite world' but not the GUI. - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.SimpleCamera() self.left_down = False self.right_down = False diff --git a/arcade/examples/sprite_tiled_map.py b/arcade/examples/sprite_tiled_map.py index 0761c38a53..e16050aecc 100644 --- a/arcade/examples/sprite_tiled_map.py +++ b/arcade/examples/sprite_tiled_map.py @@ -126,8 +126,8 @@ def setup(self): self.player_sprite, walls, gravity_constant=GRAVITY ) - self.camera = arcade.SimpleCamera() - self.gui_camera = arcade.SimpleCamera() + self.camera = arcade.camera.SimpleCamera() + self.gui_camera = arcade.camera.SimpleCamera() # Center camera on user self.pan_camera_to_user() diff --git a/arcade/examples/template_platformer.py b/arcade/examples/template_platformer.py index 35a4a165e4..9509406993 100644 --- a/arcade/examples/template_platformer.py +++ b/arcade/examples/template_platformer.py @@ -65,8 +65,8 @@ def setup(self): """Set up the game here. Call this function to restart the game.""" # Setup the Cameras - self.camera_sprites = arcade.SimpleCamera() - self.camera_gui = arcade.SimpleCamera() + self.camera_sprites = arcade.camera.SimpleCamera() + self.camera_gui = arcade.camera.SimpleCamera() # Name of map file to load map_name = ":resources:tiled_maps/map.json" diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 87bb8c5b38..23234023a7 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -30,7 +30,7 @@ ) from arcade.gui.surface import Surface from arcade.gui.widgets import UIWidget, Rect -from arcade.camera import SimpleCamera +from arcade.camera import OrthographicProjector, OrthographicProjectionData W = TypeVar("W", bound=UIWidget) @@ -90,7 +90,9 @@ def __init__(self, window: Optional[arcade.Window] = None): self.children: Dict[int, List[UIWidget]] = defaultdict(list) self._rendered = False #: Camera used when drawing the UI - self.camera = SimpleCamera() + self.projector = OrthographicProjector( + projection=OrthographicProjectionData(0, self.window.width, 0, self.window.height, -100, 100) + ) self.register_event_type("on_event") def add(self, widget: W, *, index=None, layer=0) -> W: @@ -298,7 +300,7 @@ def draw(self) -> None: self._do_render() # Draw layers - self.camera.use() + self.projector.use() with ctx.enabled(ctx.BLEND): layers = sorted(self.children.keys()) for layer in layers: @@ -317,7 +319,7 @@ def adjust_mouse_coordinates(self, x, y): """ # NOTE: Only support scrolling until cameras support transforming # mouse coordinates - px, py = self.camera.position + px, py = self.projector.view_data.position[:2] return x + px, y + py def on_event(self, event) -> Union[bool, None]: @@ -373,7 +375,7 @@ def on_text_motion_select(self, motion): def on_resize(self, width, height): scale = self.window.get_pixel_ratio() - self.camera.resize(width, height) + self.projector.view_data.viewport = (0, 0, width, height) for surface in self._surfaces.values(): surface.resize(size=(width, height), pixel_ratio=scale) diff --git a/arcade/resources/system/shaders/util/textured_quad_fs.glsl b/arcade/resources/system/shaders/util/textured_quad_fs.glsl new file mode 100644 index 0000000000..6693a83d98 --- /dev/null +++ b/arcade/resources/system/shaders/util/textured_quad_fs.glsl @@ -0,0 +1,11 @@ +#version 330 + +uniform sampler2D texture0; + +in vec2 out_uv; + +out vec4 out_colour; + +void main(){ + out_colour = texture(texture0, out_uv); +} diff --git a/arcade/resources/system/shaders/util/textured_quad_vs.glsl b/arcade/resources/system/shaders/util/textured_quad_vs.glsl new file mode 100644 index 0000000000..9b7e8eb720 --- /dev/null +++ b/arcade/resources/system/shaders/util/textured_quad_vs.glsl @@ -0,0 +1,11 @@ +#version 330 + +in vec2 in_uv; +in vec2 in_vert; + +out vec2 out_uv; + +void main(){ + out_uv = in_uv; + gl_Position = vec4(in_vert, 0.0, 1.0); +} \ No newline at end of file diff --git a/arcade/sections.py b/arcade/sections.py index 3745736882..4b5d9b76cc 100644 --- a/arcade/sections.py +++ b/arcade/sections.py @@ -3,9 +3,11 @@ from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED -from arcade import SimpleCamera, get_window +from arcade import get_window +from arcade.camera.default import DefaultProjector if TYPE_CHECKING: + from arcade.camera import Projector from arcade import View __all__ = [ @@ -99,7 +101,7 @@ def __init__(self, left: int, bottom: int, width: int, height: int, self._ec_top: int = self.window.height if self._modal else self._top # optional section camera - self.camera: Optional[SimpleCamera] = None + self.camera: Optional[Projector] = None def __repr__(self): name = f'Section {self.name}' if self.name else 'Section' @@ -325,9 +327,7 @@ def __init__(self, view: "View"): # generic camera to reset after a custom camera is use # this camera is set to the whole viewport - self.camera: SimpleCamera = SimpleCamera(viewport=(0, 0, - self.view.window.width, - self.view.window.height)) + self.camera: DefaultProjector = DefaultProjector() # Holds the section the mouse is currently on top self.mouse_over_sections: List[Section] = [] @@ -502,7 +502,7 @@ def on_resize(self, width: int, height: int) -> None: :param width: the new width of the screen :param height: the new height of the screen """ - self.camera.resize(width, height) # resize the default camera + # The Default camera auto-resizes. if self.view_resize_first is True: self.view.on_resize(width, height) # call resize on the view for section in self.sections: diff --git a/doc/api_docs/arcade.rst b/doc/api_docs/arcade.rst index 85a8c119d3..198a3d34a1 100644 --- a/doc/api_docs/arcade.rst +++ b/doc/api_docs/arcade.rst @@ -20,7 +20,6 @@ for the Python Arcade library. See also: api/sprites api/sprite_list api/sprite_scenes - api/camera api/text api/tilemap api/texture diff --git a/tests/unit/camera/test_camera_2d.py b/tests/unit/camera/test_camera_2d.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/camera/test_camera_controllers.py b/tests/unit/camera/test_camera_controllers.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/camera/test_default_camera.py b/tests/unit/camera/test_default_camera.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/camera/test_orthographic_camera.py b/tests/unit/camera/test_orthographic_camera.py new file mode 100644 index 0000000000..eced85d790 --- /dev/null +++ b/tests/unit/camera/test_orthographic_camera.py @@ -0,0 +1,90 @@ +import pytest as pytest + +from arcade import camera, Window + + +def test_orthographic_camera(window: Window): + default_camera = window.current_camera + + cam_default = camera.OrthographicProjector() + default_view = cam_default.view_data + default_projection = cam_default.projection_data + + # test that the camera correctly generated the default view and projection PoDs. + assert default_view == camera.CameraData( + (0, 0, window.width, window.height), # Viewport + (window.width/2, window.height/2, 0), # Position + (0.0, 1.0, 0.0), # Up + (0.0, 0.0, 1.0), # Forward + 1.0, # Zoom + ) + assert default_projection == camera.OrthographicProjectionData( + -0.5 * window.width, 0.5 * window.width, # Left, Right + -0.5 * window.height, 0.5 * window.height, # Bottom, Top + -100, 100 # Near, Far + ) + + # test that the camera properties work + assert cam_default.view_data.position == default_view.position + assert cam_default.view_data.viewport == default_view.viewport + + # Test that the camera is actually recognised by the camera as being activated + assert window.current_camera == default_camera + with cam_default.activate() as cam: + assert window.current_camera == cam and cam == cam_default + assert window.current_camera == default_camera + + # Test that the camera is being used. + cam_default.use() + assert window.current_camera == cam_default + default_camera.use() + assert window.current_camera == default_camera + + set_view = camera.CameraData( + (0, 0, 1, 1), # Viewport + (0.0, 0.0, 0.0), # Position + (0.0, 1.0, 0.0), # Up + (0.0, 0.0, 1.0), # Forward + 1.0 # Zoom + ) + set_projection = camera.OrthographicProjectionData( + 0.0, 1.0, # Left, Right + 0.0, 1.0, # Bottom, Top + -1.0, 1.0 # Near, Far + ) + cam_set = camera.OrthographicProjector( + view=set_view, + projection=set_projection + ) + + # test that the camera correctly used the provided Pods. + assert cam_set.view_data == set_view + assert cam_set.projection_data == set_projection + + # test that the camera properties work + assert cam_set.view_data.position == set_view.position + assert cam_set.view_data.viewport == set_view.viewport + + # Test that the camera is actually recognised by the camera as being activated + assert window.current_camera == default_camera + with cam_set.activate() as cam: + assert window.current_camera == cam and cam == cam_set + assert window.current_camera == default_camera + + # Test that the camera is being used. + cam_set.use() + assert window.current_camera == cam_set + default_camera.use() + assert window.current_camera == default_camera + + +def test_orthographic_projection_matrix(): + pass + + +def test_orthographic_view_matrix(): + pass + + +def test_orthographic_map_coordinates(): + pass diff --git a/tests/unit/camera/test_perspective_camera.py b/tests/unit/camera/test_perspective_camera.py new file mode 100644 index 0000000000..d359ba1831 --- /dev/null +++ b/tests/unit/camera/test_perspective_camera.py @@ -0,0 +1,17 @@ +import pytest as pytest + + +def test_perspective_camera(): + pass + + +def test_perspective_projection_matrix(): + pass + + +def test_perspective_view_matrix(): + pass + + +def test_perspective_map_coordinates(): + pass diff --git a/tests/unit/camera/test_simple_camera.py b/tests/unit/camera/test_simple_camera.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/util/update_quick_index.py b/util/update_quick_index.py index 610afd61b7..66b5b73458 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -15,7 +15,6 @@ titles = { 'application.py': ['Window and View', 'window.rst'], 'shape_list.py': ['Shape Lists', 'drawing_batch.rst'], - 'camera.py': ['Camera', 'camera.rst'], 'context.py': ['OpenGL Context', 'open_gl.rst'], 'drawing_support.py': ['Drawing - Utility', 'drawing_utilities.rst'], 'draw_commands.py': ['Drawing - Primitives', 'drawing_primitives.rst'], @@ -49,6 +48,7 @@ 'texture/solid_color.py': ['Texture Management', 'texture.rst'], 'texture/tools.py': ['Texture Management', 'texture.rst'], 'texture/transforms.py': ['Texture Transforms', 'texture_transforms.rst'], + 'camera/camera_2d.py': ['Camera 2D', 'camera_2d.rst'], 'math.py': ['Math', 'math.rst'], 'types.py': ['Types', 'types.rst'], 'easing.py': ['Easing', 'easing.rst'], @@ -203,6 +203,7 @@ def process_directory(directory: Path, quick_index_file): "math.py": "arcade.math", "earclip.py": "arcade.earclip", "shape_list.py": "arcade.shape_list", + "camera": "arcade.camera" } package = mapping.get(path.name, None) or mapping.get(directory.name, None)