diff --git a/arcade/camera/README.md b/arcade/camera/README.md index bfb0512ba..72c5a52a6 100644 --- a/arcade/camera/README.md +++ b/arcade/camera/README.md @@ -30,11 +30,11 @@ world space is equal to one unit in view space ### Projection Matrices The projection matrix takes the positions of objects in view space and projects them into screen space. -depending on the type of projection matrix how this exactly applies changes. Projection matrices along +depending on the type of projection matrix how this exactly applies changes. Projection matrices alone do not fully project objects into screen space, instead they transform positions into unit space. This special coordinate space ranges from -1 to 1 in the x, y, and z axis. Anything within this range will be transformed into screen space, and everything outside this range is discarded and left undrawn. -you can conceptualise projection matrices as taking a 6 sided 3D prism volume in view space and +you can conceptualise projection matrices as taking a 6 sided 3D volume in view space and squashing it down into a uniformly sized cube. In every case the closest position projected along the z-axis is given by the near value, while the furthest is given by the far value. @@ -71,8 +71,10 @@ not be drawn. - `arcade.camera.Projector` is a `Protocol` used internally by arcade - `Projector.use()` sets the internal projection and view matrices used by Arcade and Pyglet - `Projector.activate()` is the same as use, but works within a context manager using the `with` syntax - - `Projector.map_screen_to_world_coordinate(screen_coordinate, depth)` + - `Projector.unproject(screen_coordinate)` provides a way to find the world position of any pixel position on screen. + - `Projector.project(world_coordinate)` +provides a way to find the screen position of any position in the world. - There are multiple data types which provide the information required to make view and projection matrices - `camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the view matrix @@ -80,10 +82,9 @@ view matrix orthographic projection matrix - `camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a perspective projection matrix. - - both ProjectionData data types also provide a viewport for setting the draw area when using the camera. - There are three primary `Projectors` in `arcade.camera` - `arcade.camera.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. - `arcade.camera.OrthographicProjector` can be freely positioned in 3D space, but the scale of objects does not depend on the distance to the projector - - [not yet implemented ] `arcade.camera.PerspectiveProjector` can be freely position in 3D space, + - `arcade.camera.PerspectiveProjector` can be freely position in 3D space, and objects look smaller the further from the camera they are diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index a1a03ae4b..5f4c3319f 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -329,16 +329,17 @@ def equalise(self) -> None: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) - def match_screen( + def match_window( self, - and_projection: bool = True, - and_scissor: bool = True, - and_position: bool = False, + viewport: bool = True, + projection: bool = True, + scissor: bool = True, + position: bool = False, aspect: float | None = None, ) -> None: """ - Sets the viewport to the size of the screen. - Should be called when the screen is resized. + Sets the viewport to the size of the window. + Should be called when the window is resized. Args: and_projection: Flag whether to also equalize the projection to the viewport. @@ -353,96 +354,102 @@ def match_screen( compared to the height. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. """ - self.update_viewport( + self.update_values( self._window.rect, - and_projection=and_projection, - and_scissor=and_scissor, - and_position=and_position, + viewport=viewport, + projection=projection, + scissor=scissor, + position=position, aspect=aspect, ) def match_target( self, - and_projection: bool = True, - and_scissor: bool = True, - and_position: bool = False, + viewport: bool = True, + projection: bool = True, + scissor: bool = True, + position: bool = False, aspect: float | None = None, ) -> None: """ Sets the viewport to the size of the Camera2D's render target. Args: - and_projection: Flag whether to also equalize the projection to the viewport. - On by default - and_scissor: Flag whether to also equalize the scissor box to the viewport. - On by default - and_position: Flag whether to also center the camera to the viewport. + viewport: Flag whether to equalise the viewport to the area of the render target + projection: Flag whether to equalise the size of the projection to + match the render target + The projection center stays fixed, and the new projection matches only in size. + scissor: Flag whether to update the scissor value. + position: Flag whether to also center the camera to the value. Off by default - aspect_ratio: The ratio between width and height that the viewport should - be constrained to. If unset then the viewport just matches the window - size. The aspect ratio describes how much larger the width should be - compared to the height. i.e. for an aspect ratio of ``4:3`` you should + aspect_ratio: The ratio between width and height that the value should + be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. + If unset then the value will not be updated. Raises: ValueError: Will be raised if the Camera2D was has no render target. """ if self.render_target is None: raise ValueError( - "Tried to match a non-exsistant render target. Please use `match_screen` instead" + "Tried to match a non-exsistant render target. Please use `match_window` instead" ) - self.update_viewport( + self.update_values( LRBT(*self.render_target.viewport), - and_projection=and_projection, - and_scissor=and_scissor, - and_position=and_position, + viewport, + projection, + scissor, + position, aspect=aspect, ) - def update_viewport( + def update_values( self, - new_viewport: Rect, - and_projection: bool = True, - and_scissor: bool = True, - and_position: bool = False, + value: Rect, + viewport: bool = True, + projection: bool = True, + scissor: bool = True, + position: bool = False, aspect: float | None = None, ): """ - Convienence method for updating the viewport of the camera. To simply change - the viewport you can safely set the projection property. + Convienence method for updating the viewport, projection, position + and a few others with the same value. Args: - and_projection: Flag whether to also equalize the projection to the viewport. - On by default - and_scissor: Flag whether to also equalize the scissor box to the viewport. - On by default - and_position: Flag whether to also center the camera to the viewport. + value: The rect that the values will be derived from. + viewport: Flag whether to equalise the viewport to the value. + projection: Flag whether to equalise the size of the projection to match the value. + The projection center stays fixed, and the new projection matches only in size. + scissor: Flag whether to update the scissor value. + position: Flag whether to also center the camera to the value. Off by default - aspect_ratio: The ratio between width and height that the viewport should - be constrained to. If unset then the viewport just matches the window - size. The aspect ratio describes how much larger the width should be - compared to the height. i.e. for an aspect ratio of ``4:3`` you should + aspect_ratio: The ratio between width and height that the value should + be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. + If unset then the value will not be updated. """ if aspect is not None: - if new_viewport.height * aspect < new_viewport.width: - w = new_viewport.height * aspect - h = new_viewport.height + if value.height * aspect < value.width: + w = value.height * aspect + h = value.height else: - w = new_viewport.width - h = new_viewport.width / aspect - self.viewport = XYWH(new_viewport.x, new_viewport.y, w, h) - else: - self.viewport = new_viewport + w = value.width + h = value.width / aspect + value = XYWH(value.x, value.y, w, h) + + if viewport: + self.viewport = value - if and_projection: - self.equalise() + if projection: + x, y = self._projection_data.rect.x, self._projection_data.rect.y + self._projection_data.rect = XYWH(x, y, value.width, value.height) - if and_scissor and self.scissor: - self.scissor = self.viewport + if scissor and self.scissor: + self.scissor = value - if and_position: - self.position = self.viewport.center + if position: + self.position = value.center def aabb(self) -> Rect: """ @@ -898,7 +905,6 @@ def top_left(self, new_corner: Point2): # top_center @property def top_center(self) -> Vec2: - # TODO correct """Get the top most position the camera can see""" pos = self.position @@ -908,7 +914,6 @@ def top_center(self) -> Vec2: @top_center.setter def top_center(self, new_top: Point2): - # TODO correct ux, uy, *_ = self._camera_data.up top = self.top diff --git a/arcade/examples/background_blending.py b/arcade/examples/background_blending.py index edefbcde2..5a982c4d9 100644 --- a/arcade/examples/background_blending.py +++ b/arcade/examples/background_blending.py @@ -128,7 +128,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() # This is to ensure the background covers the entire screen. self.background_1.size = (width, height) diff --git a/arcade/examples/background_groups.py b/arcade/examples/background_groups.py index 4ff83285c..42d2d9d87 100644 --- a/arcade/examples/background_groups.py +++ b/arcade/examples/background_groups.py @@ -130,7 +130,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() def main(): diff --git a/arcade/examples/background_parallax.py b/arcade/examples/background_parallax.py index 0cf44519f..85c0b6b16 100644 --- a/arcade/examples/background_parallax.py +++ b/arcade/examples/background_parallax.py @@ -152,7 +152,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() full_width_size = (width, SCALED_BG_LAYER_HEIGHT_PX) # We can iterate through a background group, diff --git a/arcade/examples/background_scrolling.py b/arcade/examples/background_scrolling.py index 883c84278..00423adfe 100644 --- a/arcade/examples/background_scrolling.py +++ b/arcade/examples/background_scrolling.py @@ -98,7 +98,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() # This is to ensure the background covers the entire screen. self.background.size = (width, height) diff --git a/arcade/examples/background_stationary.py b/arcade/examples/background_stationary.py index 061d386c1..14ec658ea 100644 --- a/arcade/examples/background_stationary.py +++ b/arcade/examples/background_stationary.py @@ -102,7 +102,7 @@ def on_key_release(self, symbol: int, modifiers: int): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() def main(): diff --git a/arcade/examples/camera_platform.py b/arcade/examples/camera_platform.py index 06078d17a..09e0c1d69 100644 --- a/arcade/examples/camera_platform.py +++ b/arcade/examples/camera_platform.py @@ -163,7 +163,7 @@ def setup(self): def on_resize(self, width, height): """Resize window""" super().on_resize(width, height) - self.camera.match_screen(and_projection=True) + self.camera.match_window() def on_draw(self): """Render the screen.""" diff --git a/arcade/examples/gl/custom_sprite.py b/arcade/examples/gl/custom_sprite.py index 56e8f9793..cc4b9f21a 100644 --- a/arcade/examples/gl/custom_sprite.py +++ b/arcade/examples/gl/custom_sprite.py @@ -150,7 +150,7 @@ def __init__(self): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera.match_screen(and_position=True) + self.camera.match_window(position=True) def on_draw(self): self.clear() diff --git a/arcade/examples/light_demo.py b/arcade/examples/light_demo.py index 942529467..89bab2ebf 100644 --- a/arcade/examples/light_demo.py +++ b/arcade/examples/light_demo.py @@ -225,7 +225,7 @@ def on_draw(self): def on_resize(self, width, height): """ User resizes the screen. """ super().on_resize(width, height) - self.camera.match_screen() + self.camera.match_window() # --- Light related --- # We need to resize the light layer to diff --git a/arcade/examples/minimap_camera.py b/arcade/examples/minimap_camera.py index 7a82708a7..9035490e4 100644 --- a/arcade/examples/minimap_camera.py +++ b/arcade/examples/minimap_camera.py @@ -222,8 +222,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen() - self.camera_gui.match_screen(and_position=True) + self.camera_sprites.match_window() + self.camera_gui.match_window(position=True) self.camera_minimap.viewport = arcade.LBWH( width - self.camera_minimap.viewport_width, height - self.camera_minimap.viewport_height, diff --git a/arcade/examples/minimap_texture.py b/arcade/examples/minimap_texture.py index 333a56a23..4837d303e 100644 --- a/arcade/examples/minimap_texture.py +++ b/arcade/examples/minimap_texture.py @@ -203,8 +203,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True) + self.camera_sprites.match_window() + self.camera_gui.match_window() def main(): diff --git a/arcade/examples/procedural_caves_cellular.py b/arcade/examples/procedural_caves_cellular.py index c911a0a36..907f92835 100644 --- a/arcade/examples/procedural_caves_cellular.py +++ b/arcade/examples/procedural_caves_cellular.py @@ -328,8 +328,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True) + self.camera_sprites.match_window() + self.camera_gui.match_window() def on_update(self, delta_time): """ Movement and game logic """ diff --git a/arcade/examples/sprite_move_scrolling.py b/arcade/examples/sprite_move_scrolling.py index 4d9045aab..121e304e3 100644 --- a/arcade/examples/sprite_move_scrolling.py +++ b/arcade/examples/sprite_move_scrolling.py @@ -181,8 +181,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True) + self.camera_sprites.match_window() + self.camera_gui.match_window() def main(): diff --git a/arcade/examples/sprite_move_scrolling_box.py b/arcade/examples/sprite_move_scrolling_box.py index b8a951c63..880a8f02a 100644 --- a/arcade/examples/sprite_move_scrolling_box.py +++ b/arcade/examples/sprite_move_scrolling_box.py @@ -211,8 +211,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True, and_position=True) + self.camera_sprites.match_window() + self.camera_gui.match_window(position=True) def main(): diff --git a/arcade/examples/sprite_move_scrolling_shake.py b/arcade/examples/sprite_move_scrolling_shake.py index 6122c7001..b4e0b65e6 100644 --- a/arcade/examples/sprite_move_scrolling_shake.py +++ b/arcade/examples/sprite_move_scrolling_shake.py @@ -196,8 +196,8 @@ def on_resize(self, width: int, height: int): Handle the user grabbing the edge and resizing the window. """ super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True, and_position=True) + self.camera_sprites.match_window() + self.camera_gui.match_window(position=True) def main(): diff --git a/arcade/examples/template_platformer.py b/arcade/examples/template_platformer.py index 37d3f9080..da7b62ba8 100644 --- a/arcade/examples/template_platformer.py +++ b/arcade/examples/template_platformer.py @@ -216,9 +216,9 @@ def on_resize(self, width: int, height: int): """ Resize window """ super().on_resize(width, height) # Update the cameras to match the new window size - self.camera_sprites.match_screen(and_projection=True) - # The and_position property keeps `0, 0` in the bottom left corner. - self.camera_gui.match_screen(and_projection=True, and_position=True) + self.camera_sprites.match_window() + # The position argument keeps `0, 0` in the bottom left corner. + self.camera_gui.match_window(position=True) def main(): diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index 22264bacb..4b047fb68 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -432,12 +432,12 @@ def on_resize(self, width, height): """Resize the UIManager and all of its surfaces.""" # resize ui camera bottom_left = self.camera.bottom_left - self.camera.match_screen() + self.camera.match_window() self.camera.bottom_left = bottom_left # resize render to surface camera bottom_left = self._render_to_surface_camera.bottom_left - self._render_to_surface_camera.match_screen() + self._render_to_surface_camera.match_window() self._render_to_surface_camera.bottom_left = bottom_left scale = self.window.get_pixel_ratio() diff --git a/arcade/types/rect.py b/arcade/types/rect.py index 038e086fd..8ee9fe08c 100644 --- a/arcade/types/rect.py +++ b/arcade/types/rect.py @@ -717,7 +717,7 @@ def __str__(self) -> str: def __bool__(self) -> bool: """Returns True if area is not 0, else False.""" - return self.width != 0 or self.height != 0 + return self.width != 0 and self.height != 0 def __round__(self, n: int) -> Rect: """Rounds the left, right, bottom, and top to `n` decimals.""" diff --git a/doc/programming_guide/camera.rst b/doc/programming_guide/camera.rst index 5a787fc84..4efeb2a5e 100644 --- a/doc/programming_guide/camera.rst +++ b/doc/programming_guide/camera.rst @@ -1,2 +1,98 @@ Camera ====== + +This is a rough overview of how the Cameras work. Updating and improving this documentation is on the roadmap. + +Key Concepts +------------ + +World Space +^^^^^^^^^^^ +Whenever an object has a position within Arcade that position is in world space. How much 1 unit in world +space represents is arbitrary. For example when a sprite has a scale of 1.0 then 1 unit in world space is +equal to one pixel of the sprite's source texture. This does not necessarily equate to one pixel on the screen. + +Screen Space +^^^^^^^^^^^^ +The final positions of anything drawn to screen is in screen space. The mouse positions returned by window +events like :py:func:`on_mouse_press` are also in screen space. Moving 1 unit in screen space is equivalent to moving +one pixel. Often positions in screen space are integer values, but this is not a strict rule. + +View Matrices +^^^^^^^^^^^^^ +The view matrix represents what part of world space should be focused on. It is made of three components. +The first is the position. This represents what world space position should be at (0, 0, 0). The second is +the forward vector. This is the direction which is considered forward and backwards in world space. the +final component is the up vector. Which determines what world space positions are upwards or downwards in +world space. + +The goal of the view matrix is to prepare everything in world space for projection into screen space. It +achieves this by applying its three components to every world space position. In the end any object with +a world space position equal to the view matrix position will be at (0, 0, 0). Any object along the forward +vector after moving will be placed along the z-axis, and any object along the up vector will be place along +the y-axis. This transformation moves the objects from screen space into view space. Importantly one unit in +world space is equal to one unit in view space + +Projection Matrices +^^^^^^^^^^^^^^^^^^^ +The projection matrix takes the positions of objects in view space and projects them into screen space. +depending on the type of projection matrix how this exactly applies changes. Projection matrices alone +do not fully project objects into screen space, instead they transform positions into unit space. This +special coordinate space ranges from -1 to 1 in the x, y, and z axis. Anything within this range will +be transformed into screen space, and everything outside this range is discarded and left undrawn. +you can conceptualise projection matrices as taking a 6 sided 3D volume in view space and +squashing it down into a uniformly sized cube. In every case the closest position projected along the +z-axis is given by the near value, while the furthest is given by the far value. + +orthographic +"""""""""""" +In an orthographic projection the distance from the origin does not impact how much a position gets projected. +This type of projection can be visualised as a rectangular prism with a set width, height, and depth +determined by left, right, bottom, top, near, far values. These values tell you the bounding box of positions +in view space which get projected. + +perspective +""""""""""" +In an orthographic projection the distance from the origin directly impacts how much a position is projected. +This type of projection can be visualised as a rectangular prism with the sharp end removed. This shape means +that more positions further away from the origin will be squashed down into unit space. This makes objects +that are further away appear smaller. The shape of the prism is determined by an aspect ratio, the field of view, +and the near and far values. The aspect ratio defines the ratio between the height and width of the projection. +The field of view is half of the angle used to determine the height of the projection at a particular depth. + +Viewports +^^^^^^^^^ +The final concept to cover is the viewport. This is the pixel area which the unit space will scale to. The ratio +between the size of the viewport and the size of the projection determines the relationship between units in +world space and pixels in screen space. For example if width and height of an orthographic projection is equal +to the width and height of the viewport then one unit in world space will equal one pixel in screen space. This +is the default for arcade. + +The viewport also defines which pixels get drawn to in the final image. Generally this is equal to the entire +screen, but it is possible to draw to only a specific area by defining the right viewport. Note that doing this +will change the ratio of the viewport and projection, so ensure that they match if you would like to keep the same +unit to pixel ratio. Any position outside the viewport which would normally be a valid pixel position will +not be drawn. + +Key Objects +----------- + +- Objects which modify the view and perspective matrices are called "Projectors" + + - :py:class:`arcade.camera.Projector` is a :py:class:`Protocol` used internally by arcade + - :py:func:`Projector.use()` sets the internal projection and view matrices used by Arcade and Pyglet + - :py:func:`Projector.activate()` is the same as use, but works within a context manager using the ``with`` syntax + - :py:func:`Projector.unproject(screen_coordinate)` provides a way to find the world position of any pixel position on screen. + - :py:func:`Projector.project(world_coordinate)` provides a way to find the screen position of any position in the world. + +- There are multiple data types which provide the information required to make view and projection matrices + + - :py:class:`camera.CameraData` holds the position, forward, and up vectors along with a zoom value used to create the view matrix + - :py:class:`camera.OrthographicProjectionData` holds the left, right, bottom, top, near, far values needed to create a orthographic projection matrix + - :py:class:`camera.PerspectiveProjectionData` holds the aspect ratio, field of view, near and far needed to create a perspective projection matrix. + +- There are three primary `Projectors` in `arcade.camera` + + - :py:class:`arcade.camera.Camera2D` is locked to the x-y plane and is perfect for most use cases within arcade. + - :py:class:`arcade.camera.OrthographicProjector` can be freely positioned in 3D space, and the scale of objects does not depend on the distance from the projector. + - :py:class:`arcade.camera.PerspectiveProjector` can be freely positioned in 3D space, and objects look smaller the further from the camera they are. diff --git a/doc/tutorials/raycasting/step_08.py b/doc/tutorials/raycasting/step_08.py index 64f1329cd..22df7eccd 100644 --- a/doc/tutorials/raycasting/step_08.py +++ b/doc/tutorials/raycasting/step_08.py @@ -192,8 +192,8 @@ def scroll_to_player(self, speed=CAMERA_SPEED): def on_resize(self, width: int, height: int): super().on_resize(width, height) - self.camera_sprites.match_screen(and_projection=True) - self.camera_gui.match_screen(and_projection=True) + self.camera_sprites.match_window() + self.camera_gui.match_window() self.shadertoy.resize((width, height))