Skip to content

Commit

Permalink
Give Projectors Project and Unproject methods. (#2082)
Browse files Browse the repository at this point in the history
* Make generic matrix generation methods and make projectors use them

* Correct typing issue in generate_orthographic_matrix

* updating unit tests and improving orthographic projector `unproject` method

* Add Project and Unproject methods

* linting corrections

* make all Projectors `unproject` return a 3-tuple

* Touchup touchups (#5)

* Add docstrings for Projector, Projector.use, and Project.activate

* Add docstring for Projection Protocol

* Move PerspectiveProjector.activate onto Projector protocol & subclass

* Replace Orthographic Projector's activate with Protocol default implementation

* Moving activate back into projectors

sadly it is harder to make generic than anticipated

* Move `current_camera` and `default_camera` to the `ArcadeContext` so they can be reliably updated when viewport is changed

* Typing extensions for the win

* Typing extensions for the win: revengance

* Linty Linty

---------

Co-authored-by: Paul <[email protected]>
  • Loading branch information
DragonMoffon and pushfoo authored Apr 28, 2024
1 parent 5bb27ec commit 0ad8f85
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 163 deletions.
39 changes: 31 additions & 8 deletions arcade/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
from arcade.types import Color, RGBOrA255, RGBANormalized
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__)

Expand Down Expand Up @@ -217,8 +215,6 @@ def __init__(
self._background_color: Color = TRANSPARENT_BLACK

self._current_view: Optional[View] = None
self._default_camera = DefaultProjector(window=self)
self.current_camera: Projector = self._default_camera
self.textbox_time = 0.0
self.key: Optional[int] = None
self.flip_count: int = 0
Expand Down Expand Up @@ -611,8 +607,7 @@ def on_resize(self, width: int, height: int):
# The arcade context is not created at that time
if hasattr(self, "_ctx"):
# Retain projection scrolling if applied
self._ctx.viewport = (0, 0, width, height)
self.default_camera.use()
self.viewport = (0, 0, width, height)

def set_min_size(self, width: int, height: int):
""" Wrap the Pyglet window call to set minimum size
Expand Down Expand Up @@ -685,9 +680,37 @@ def default_camera(self):
"""
Provides a reference to the default arcade camera.
Automatically sets projection and view to the size
of the screen. Good for resetting the screen.
of the screen.
"""
return self._default_camera
return self._ctx._default_camera

@property
def current_camera(self):
"""
Get/Set the current camera. This represents the projector
currently being used to define the projection and view matrices.
"""
return self._ctx.current_camera

@current_camera.setter
def current_camera(self, next_camera):
self._ctx.current_camera = next_camera

@property
def viewport(self) -> tuple[int, int, int, int]:
"""
Get/Set the viewport of the window. This is the viewport used for
on-screen rendering. If the screen is in use it will also update the
default camera.
"""
return self.screen.viewport

@viewport.setter
def viewport(self, new_viewport: tuple[int, int, int, int]):
if self.screen == self._ctx.active_framebuffer:
self._ctx.viewport = new_viewport
else:
self.screen.viewport = new_viewport

def test(self, frames: int = 10):
"""
Expand Down
9 changes: 9 additions & 0 deletions arcade/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
PerspectiveProjectionData
)

from arcade.camera.projection_functions import (
generate_view_matrix,
generate_orthographic_matrix,
generate_perspective_matrix
)

from arcade.camera.orthographic import OrthographicProjector
from arcade.camera.perspective import PerspectiveProjector

Expand All @@ -23,9 +29,12 @@
'Projection',
'Projector',
'CameraData',
'generate_view_matrix',
'OrthographicProjectionData',
'generate_orthographic_matrix',
'OrthographicProjector',
'PerspectiveProjectionData',
'generate_perspective_matrix',
'PerspectiveProjector',
'Camera2D',
'grips'
Expand Down
37 changes: 25 additions & 12 deletions arcade/camera/camera_2d.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Optional, Tuple, Iterator
from typing import TYPE_CHECKING, Optional, Tuple, Generator
from math import degrees, radians, atan2, cos, sin
from contextlib import contextmanager

Expand All @@ -8,7 +8,6 @@
from arcade.camera.data_types import (
CameraData,
OrthographicProjectionData,
Projector,
ZeroProjectionDimension
)
from arcade.gl import Framebuffer
Expand Down Expand Up @@ -781,7 +780,7 @@ def use(self) -> None:
self._ortho_projector.use()

@contextmanager
def activate(self) -> Iterator[Projector]:
def activate(self) -> Generator[Self, None, None]:
"""
Set internal projector as window projector,
and set the projection and view matrix.
Expand All @@ -800,11 +799,15 @@ def activate(self) -> Iterator[Projector]:
previous_framebuffer.use()
previous_projection.use()

def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: Optional[float] = 0.0
) -> Tuple[float, float]:
def project(self, world_coordinate: Tuple[float, ...]) -> Tuple[float, float]:
"""
Take a Vec2 or Vec3 of coordinates and return the related screen coordinate
"""
return self._ortho_projector.project(world_coordinate)

def unproject(self,
screen_coordinate: Tuple[float, float],
depth: Optional[float] = None) -> Tuple[float, float, float]:
"""
Take in a pixel coordinate from within
the range of the window size and returns
Expand All @@ -815,12 +818,22 @@ def map_screen_to_world_coordinate(
Args:
screen_coordinate: A 2D position in pixels from the bottom left of the screen.
This should ALWAYS be in the range of 0.0 - screen size.
depth: The depth value which is mapped along with the screen coordinates. Because of how
Orthographic perspectives work this does not impact how the screen_coordinates are mapped.
depth: The depth of the query
Returns:
A 2D vector (Along the XY plane) in world space (same as sprites).
A 3D vector in world space (same as sprites).
perfect for finding if the mouse overlaps with a sprite or ui element irrespective
of the camera.
"""

return self._ortho_projector.map_screen_to_world_coordinate(screen_coordinate, depth)[:2]
return self._ortho_projector.unproject(screen_coordinate, depth)

def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: Optional[float] = None
) -> Tuple[float, float, float]:
"""
Alias to Camera2D.unproject() for typing completion
"""
return self.unproject(screen_coordinate, depth)

97 changes: 95 additions & 2 deletions arcade/camera/data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
wide usage throughout Arcade's camera code.
"""
from __future__ import annotations
from typing import Protocol, Tuple, Iterator, Optional
from typing import Protocol, Tuple, Iterator, Optional, Generator
from contextlib import contextmanager

from typing_extensions import Self
from pyglet.math import Vec3


Expand Down Expand Up @@ -184,18 +185,110 @@ def __repr__(self):


class Projection(Protocol):
"""Matches the data universal in Arcade's projection data objects.
There are multiple types of projections used in games, but all the
common ones share key features. This :py:class:`~typing.Protocol`:
#. Defines those shared elements
#. Annotates these in code for both humans and automated type
checkers
The specific implementations which match it are used inside of
implementations of Arcade's :py:class:`.Projector` behavior. All
of these projectors rely on a ``viewport`` as well as ``near`` and
``far`` values.
The ``viewport`` is measured in screen pixels. By default, the
conventions for this are the same as the rest of Arcade and
OpenGL:
* X is measured rightward from left of the screen
* Y is measured up from the bottom of the screen
Although the ``near`` and ``far`` values are describe the cutoffs
for what the camera sees in world space, the exact meaning differs
between projection type.
.. list-table::
:header-rows: 1
* - Common Projection Type
- Meaning of ``near`` & ``far``
* - Simple Orthographic
- The Z position in world space
* - Perspective & Isometric
- Where the rear and front clipping planes sit along a
camera's :py:attr:`.CameraData.forward` vector.
"""
viewport: Tuple[int, int, int, int]
near: float
far: float


class Projector(Protocol):
"""Projects from world coordinates to viewport pixel coordinates.
Projectors also support converting in the opposite direction from
screen pixel coordinates to world space coordinates.
The two key spatial methods which do this are:
.. list-table::
:header-rows: 1
* - Method
- Action
* - :py:meth:`.project`
- Turn world coordinates into pixel coordinates relative
to the origin (bottom left by default).
* - :py:meth:`.unproject`
- Convert screen pixel coordinates into world space.
.. note: Every :py:class:`.Camera` is also a kind of projector.
The other required methods are for helping manage which camera is
currently used to draw.
"""

def use(self) -> None:
"""Set the GL context to use this projector and its settings.
.. warning:: You may be looking for:py:meth:`.activate`!
This method only sets rendering state for a given
projector. Since it doesn't restore any afterward,
it's easy to misuse in ways which can cause bugs
or temporarily break a game's rendering until
relaunch. For reliable, automatic clean-up see
the :py:meth:`.activate` method instead.
If you are implementing your own custom projector, this method
should only:
#. Set the Arcade :py:class:`~arcade.Window`'s
:py:attr:`~arcade.Window.current_camera` to this object
#. Calculate any required view and projection matrices
#. Set any resulting values on the current
:py:class:`~arcade.context.ArcadeContext`, including the:
* :py:attr:`~arcade.context.ArcadeContext.viewport`
* :py:attr:`~arcade.context.ArcadeContext.view_matrix`
* :py:attr:`~arcade.context.ArcadeContext.projection_matrix`
This method should **never** handle cleanup. That is the
responsibility of :py:attr:`.activate`.
"""
...

@contextmanager
def activate(self) -> Iterator[Projector]:
def activate(self) -> Generator[Self, None, None]:
...

def map_screen_to_world_coordinate(
Expand Down
Loading

0 comments on commit 0ad8f85

Please sign in to comment.