diff --git a/arcade/camera/__init__.py b/arcade/camera/__init__.py index 902e8e4027..5a7ad024ab 100644 --- a/arcade/camera/__init__.py +++ b/arcade/camera/__init__.py @@ -23,6 +23,7 @@ from arcade.camera.orthographic import OrthographicProjector from arcade.camera.perspective import PerspectiveProjector +from arcade.camera.viewport import ViewportProjector from arcade.camera.camera_2d import Camera2D @@ -33,6 +34,7 @@ "Projection", "Projector", "CameraData", + "ViewportProjector", "generate_view_matrix", "OrthographicProjectionData", "generate_orthographic_matrix", diff --git a/arcade/camera/default.py b/arcade/camera/default.py index dcb94d4b9f..fbf8504880 100644 --- a/arcade/camera/default.py +++ b/arcade/camera/default.py @@ -5,66 +5,134 @@ from typing import TYPE_CHECKING from pyglet.math import Mat4, Vec2, Vec3 +from pyglet.window.key import F from typing_extensions import Self +from arcade.camera.data_types import DEFAULT_FAR, DEFAULT_NEAR_ORTHO from arcade.types import LBWH, Point, Rect from arcade.window_commands import get_window if TYPE_CHECKING: from arcade.context import ArcadeContext -__all__ = ["ViewportProjector", "DefaultProjector"] +__all__ = () -class ViewportProjector: +class DefaultProjector: """ - A simple Projector which does not rely on any camera PoDs. + 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. + + The default viewport tries it's best to allow + simple usecases with no need to use a camera. - Does not have a way of moving, rotating, or zooming the camera. - perfect for something like UI or for mapping to an offscreen framebuffer. + It does this by defaulting to the size of the active + framebuffer. If the user sets the framebuffer's viewport + without a camera then the default camera will match it + until the framebuffer is changed again. Args: - viewport: The viewport to project to. - context: The window context to bind the camera to. Defaults to the currently active window. + context: The window context to bind the camera to. Defaults to the currently active context. """ - def __init__( - self, - viewport: Rect | None = None, - *, - context: ArcadeContext | None = None, - ): + def __init__(self, *, context: ArcadeContext | None = None): self._ctx: ArcadeContext = context or get_window().ctx - self._viewport: Rect = viewport or LBWH(*self._ctx.viewport) - self._projection_matrix: Mat4 = Mat4.orthogonal_projection( - 0.0, self._viewport.width, 0.0, self._viewport.height, -100, 100 - ) + self._viewport: Rect | None = None + self._scissor: Rect | None = None + self._matrix: Mat4 | None = None + self._updating: bool = False - @property - def viewport(self) -> Rect: + def update_viewport(self): """ - The viewport use to derive projection and view matrix. + Called when the ArcadeContext's viewport or active + framebuffer has been set. It only actually updates + the viewport if no other camera is active. Also + setting the viewport to match the size of the active + framebuffer sets the viewport to None. """ + + # If another camera is active then the viewport was probably set + # by camera.use() + if self._ctx.current_camera != self or self._updating: + return + self._updating = True + + if ( + self._ctx.viewport[2] != self._ctx.fbo.width + or self._ctx.viewport[3] != self._ctx.fbo.height + ): + self.viewport = LBWH(*self._ctx.viewport) + else: + self.viewport = None + + self.use() + self._updating = False + + @property + def viewport(self) -> Rect | None: return self._viewport @viewport.setter - def viewport(self, viewport: Rect) -> None: + def viewport(self, viewport: Rect | None) -> None: + if viewport == self._viewport: + return self._viewport = viewport - self._projection_matrix = Mat4.orthogonal_projection( - 0, viewport.width, 0, viewport.height, -100, 100 + self._matrix = Mat4.orthogonal_projection( + 0, self.width, 0, self.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR ) + @viewport.deleter + def viewport(self): + self.viewport = None + + @property + def scissor(self) -> Rect | None: + return self._scissor + + @scissor.setter + def scissor(self, scissor: Rect | None) -> None: + self._scissor = scissor + + @scissor.deleter + def scissor(self) -> None: + self._scissor = None + + @property + def width(self) -> int: + if self._viewport is not None: + return int(self._viewport.width) + return self._ctx.fbo.width + + @property + def height(self) -> int: + if self._viewport is not None: + return int(self._viewport.height) + return self._ctx.fbo.height + + def get_current_viewport(self) -> tuple[int, int, int, int]: + if self._viewport is not None: + return self._viewport.lbwh_int + return (0, 0, self._ctx.fbo.width, self._ctx.fbo.height) + def use(self) -> None: """ - Set the window's projection and view matrix. - Also sets the projector as the windows current camera. + Set the window's Projection and View matrices. """ - self._ctx.current_camera = self - self._ctx.viewport = self.viewport.lbwh_int # get the integer 4-tuple LBWH + viewport = self.get_current_viewport() + + self._ctx.current_camera = self + if self._ctx.viewport != viewport: + self._ctx.viewport = viewport + self._ctx.scissor = None if self._scissor is None else self._scissor.lbwh_int self._ctx.view_matrix = Mat4() - self._ctx.projection_matrix = self._projection_matrix + if self._matrix is None: + self._matrix = Mat4.orthogonal_projection( + 0, viewport[2], 0, viewport[3], DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + self._ctx.projection_matrix = self._matrix @contextmanager def activate(self) -> Generator[Self, None, None]: @@ -74,10 +142,12 @@ def activate(self) -> Generator[Self, None, None]: usable with the 'with' block. e.g. 'with ViewportProjector.activate() as cam: ...' """ previous = self._ctx.current_camera + previous_viewport = self._ctx.viewport try: self.use() yield self finally: + self._ctx.viewport = previous_viewport previous.use() def project(self, world_coordinate: Point) -> Vec2: @@ -97,46 +167,3 @@ def unproject(self, screen_coordinate: Point) -> Vec3: z = 0.0 if not _z else _z[0] return Vec3(x, y, z) - - -# 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(ViewportProjector): - """ - 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. - - Args: - context: The window context to bind the camera to. Defaults to the currently active window. - """ - - def __init__(self, *, context: ArcadeContext | None = None): - super().__init__(context=context) - - def use(self) -> None: - """ - Set the window's Projection and View matrices. - - cache's the window viewport to determine the projection matrix. - """ - - viewport = self.viewport.lbwh_int - # If the viewport is correct and the default camera is in use, - # then don't waste time resetting the view and projection matrices - if self._ctx.viewport == viewport and self._ctx.current_camera == self: - return - - # If the viewport has changed while the default camera is active then the - # default needs to update itself. - # If it was another camera's viewport being used the default camera should not update. - if self._ctx.viewport != viewport and self._ctx.current_camera == self: - self.viewport = LBWH(*self._ctx.viewport) - else: - self._ctx.viewport = viewport - - self._ctx.current_camera = self - - self._ctx.view_matrix = Mat4() - self._ctx.projection_matrix = self._projection_matrix diff --git a/arcade/camera/viewport.py b/arcade/camera/viewport.py new file mode 100644 index 0000000000..42173dd148 --- /dev/null +++ b/arcade/camera/viewport.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from pyglet.math import Mat4, Vec2, Vec3 +from typing_extensions import Self + +from arcade.camera.data_types import DEFAULT_FAR, DEFAULT_NEAR_ORTHO +from arcade.types import LBWH, Point, Rect +from arcade.window_commands import get_window + +if TYPE_CHECKING: + from arcade.context import ArcadeContext + +__all__ = ["ViewportProjector"] + + +class ViewportProjector: + """ + A simple Projector which does not rely on any camera PoDs. + + Does not have a way of moving, rotating, or zooming the camera. + perfect for something like UI or for mapping to an offscreen framebuffer. + + Args: + viewport: The viewport to project to. + context: The window context to bind the camera to. Defaults to the currently active window. + """ + + def __init__( + self, + viewport: Rect | None = None, + *, + context: ArcadeContext | None = None, + ): + self._ctx: ArcadeContext = context or get_window().ctx + self._viewport: Rect = viewport or LBWH(*self._ctx.viewport) + self._projection_matrix: Mat4 = Mat4.orthogonal_projection( + 0.0, self.viewport.width, 0.0, self.viewport.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + + @property + def viewport(self) -> Rect | None: + """ + The viewport use to derive projection and view matrix. + """ + return self._viewport + + @viewport.setter + def viewport(self, viewport: Rect) -> None: + self._viewport = viewport + self._projection_matrix = Mat4.orthogonal_projection( + 0, viewport.width, 0, viewport.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR + ) + + def use(self) -> None: + """ + Set the window's projection and view matrix. + Also sets the projector as the windows current camera. + """ + self._ctx.current_camera = self + + if self.viewport: + self._ctx.viewport = self.viewport.lbwh_int + + self._ctx.view_matrix = Mat4() + self._ctx.projection_matrix = self._projection_matrix + + @contextmanager + def activate(self) -> Generator[Self, None, None]: + """ + The context manager version of the use method. + + usable with the 'with' block. e.g. 'with ViewportProjector.activate() as cam: ...' + """ + previous = self._ctx.current_camera + previous_viewport = self._ctx.viewport + try: + self.use() + yield self + finally: + self._ctx.viewport = previous_viewport + previous.use() + + def project(self, world_coordinate: Point) -> Vec2: + """ + Take a Vec2 or Vec3 of coordinates and return the related screen coordinate + """ + x, y, *z = world_coordinate + return Vec2(x, y) + + def unproject(self, screen_coordinate: Point) -> Vec3: + """ + Map the screen pos to screen_coordinates. + + Due to the nature of viewport projector this does not do anything. + """ + x, y, *_z = screen_coordinate + z = 0.0 if not _z else _z[0] + + return Vec3(x, y, z) diff --git a/arcade/context.py b/arcade/context.py index 2cbe21a85f..b695fd19fe 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -372,6 +372,15 @@ def default_atlas(self) -> TextureAtlasBase: return self._atlas + @property + def active_framebuffer(self): + return self._active_framebuffer + + @active_framebuffer.setter + def active_framebuffer(self, framebuffer: Framebuffer): + self._active_framebuffer = framebuffer + self._default_camera.update_viewport() + @property def viewport(self) -> tuple[int, int, int, int]: """ @@ -393,8 +402,7 @@ def viewport(self) -> tuple[int, int, int, int]: @viewport.setter def viewport(self, value: tuple[int, int, int, int]): self.active_framebuffer.viewport = value - if self._default_camera == self.current_camera: - self._default_camera.use() + self._default_camera.update_viewport() @property def projection_matrix(self) -> Mat4: diff --git a/arcade/gl/context.py b/arcade/gl/context.py index 7ba7188e21..41131dc67d 100644 --- a/arcade/gl/context.py +++ b/arcade/gl/context.py @@ -221,7 +221,7 @@ def __init__( # Tracking active program self.active_program: Program | ComputeShader | None = None # Tracking active framebuffer. On context creation the window is the default render target - self.active_framebuffer: Framebuffer = self._screen + self._active_framebuffer: Framebuffer = self._screen self._stats: ContextStats = ContextStats(warn_threshold=1000) self._primitive_restart_index = -1 @@ -327,7 +327,18 @@ def fbo(self) -> Framebuffer: """ Get the currently active framebuffer (read only). """ - return self.active_framebuffer + return self._active_framebuffer + + @property + def active_framebuffer(self) -> Framebuffer: + """ + Get the currently active framebuffer. + """ + return self._active_framebuffer + + @active_framebuffer.setter + def active_framebuffer(self, framebuffer: Framebuffer) -> None: + self._active_framebuffer = framebuffer def gc(self) -> int: """ diff --git a/tests/unit/camera/test_viewport_projector.py b/tests/unit/camera/test_viewport_projector.py index f99098ff1a..56fe0fa48f 100644 --- a/tests/unit/camera/test_viewport_projector.py +++ b/tests/unit/camera/test_viewport_projector.py @@ -8,13 +8,13 @@ @pytest.mark.parametrize("wrld_pos", [Vec2(100, 150), Vec2(1280, 720), Vec3(500, 500, -10)]) def test_viewport_projector_project(window: Window, wrld_pos: Point): - cam = camera.default.ViewportProjector() + cam = camera.ViewportProjector() assert cam.project(wrld_pos) == wrld_pos.xy @pytest.mark.parametrize("wrld_pos", [Vec2(100, 150), Vec2(1280, 720), Vec3(500, 500, -10)]) def test_viewport_projector_unproject(window: Window, wrld_pos: Point): - cam = camera.default.ViewportProjector() + cam = camera.ViewportProjector() x, y, *z = wrld_pos assert cam.unproject(wrld_pos) == Vec3(x, y, 0.0 if not z else z[0]) @@ -24,7 +24,7 @@ def test_viewport_projector_unproject(window: Window, wrld_pos: Point): "viewport", [LBWH(0.0, 0.0, 100, 200), LBWH(100, 100, 20, 40), LBWH(300, 20, 20, 700)] ) def test_viewport_projector_viewport(window: Window, viewport: Rect): - cam = camera.default.ViewportProjector() + cam = camera.ViewportProjector() assert cam.viewport.lbwh_int == window.ctx.viewport cam.viewport = viewport assert cam.viewport == viewport diff --git a/tests/unit/draw/test_drawing_primitives.py b/tests/unit/draw/test_drawing_primitives.py index 8c3d81ced9..ee90f60665 100644 --- a/tests/unit/draw/test_drawing_primitives.py +++ b/tests/unit/draw/test_drawing_primitives.py @@ -6,6 +6,7 @@ def test_draw_primitives(window): Render the screen. """ window.background_color = arcade.color.WHITE + window.viewport = (0, 0, window.width, window.height) window.clear() # Draw a grid