Skip to content

Commit 0ad8f85

Browse files
Give Projectors Project and Unproject methods. (#2082)
* 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 <36696816+pushfoo@users.noreply.github.com>
1 parent 5bb27ec commit 0ad8f85

11 files changed

+492
-163
lines changed

arcade/application.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
from arcade.types import Color, RGBOrA255, RGBANormalized
2424
from arcade import SectionManager
2525
from arcade.utils import is_raspberry_pi
26-
from arcade.camera import Projector
27-
from arcade.camera.default import DefaultProjector
2826

2927
LOG = logging.getLogger(__name__)
3028

@@ -217,8 +215,6 @@ def __init__(
217215
self._background_color: Color = TRANSPARENT_BLACK
218216

219217
self._current_view: Optional[View] = None
220-
self._default_camera = DefaultProjector(window=self)
221-
self.current_camera: Projector = self._default_camera
222218
self.textbox_time = 0.0
223219
self.key: Optional[int] = None
224220
self.flip_count: int = 0
@@ -611,8 +607,7 @@ def on_resize(self, width: int, height: int):
611607
# The arcade context is not created at that time
612608
if hasattr(self, "_ctx"):
613609
# Retain projection scrolling if applied
614-
self._ctx.viewport = (0, 0, width, height)
615-
self.default_camera.use()
610+
self.viewport = (0, 0, width, height)
616611

617612
def set_min_size(self, width: int, height: int):
618613
""" Wrap the Pyglet window call to set minimum size
@@ -685,9 +680,37 @@ def default_camera(self):
685680
"""
686681
Provides a reference to the default arcade camera.
687682
Automatically sets projection and view to the size
688-
of the screen. Good for resetting the screen.
683+
of the screen.
689684
"""
690-
return self._default_camera
685+
return self._ctx._default_camera
686+
687+
@property
688+
def current_camera(self):
689+
"""
690+
Get/Set the current camera. This represents the projector
691+
currently being used to define the projection and view matrices.
692+
"""
693+
return self._ctx.current_camera
694+
695+
@current_camera.setter
696+
def current_camera(self, next_camera):
697+
self._ctx.current_camera = next_camera
698+
699+
@property
700+
def viewport(self) -> tuple[int, int, int, int]:
701+
"""
702+
Get/Set the viewport of the window. This is the viewport used for
703+
on-screen rendering. If the screen is in use it will also update the
704+
default camera.
705+
"""
706+
return self.screen.viewport
707+
708+
@viewport.setter
709+
def viewport(self, new_viewport: tuple[int, int, int, int]):
710+
if self.screen == self._ctx.active_framebuffer:
711+
self._ctx.viewport = new_viewport
712+
else:
713+
self.screen.viewport = new_viewport
691714

692715
def test(self, frames: int = 10):
693716
"""

arcade/camera/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
PerspectiveProjectionData
1212
)
1313

14+
from arcade.camera.projection_functions import (
15+
generate_view_matrix,
16+
generate_orthographic_matrix,
17+
generate_perspective_matrix
18+
)
19+
1420
from arcade.camera.orthographic import OrthographicProjector
1521
from arcade.camera.perspective import PerspectiveProjector
1622

@@ -23,9 +29,12 @@
2329
'Projection',
2430
'Projector',
2531
'CameraData',
32+
'generate_view_matrix',
2633
'OrthographicProjectionData',
34+
'generate_orthographic_matrix',
2735
'OrthographicProjector',
2836
'PerspectiveProjectionData',
37+
'generate_perspective_matrix',
2938
'PerspectiveProjector',
3039
'Camera2D',
3140
'grips'

arcade/camera/camera_2d.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Optional, Tuple, Iterator
1+
from typing import TYPE_CHECKING, Optional, Tuple, Generator
22
from math import degrees, radians, atan2, cos, sin
33
from contextlib import contextmanager
44

@@ -8,7 +8,6 @@
88
from arcade.camera.data_types import (
99
CameraData,
1010
OrthographicProjectionData,
11-
Projector,
1211
ZeroProjectionDimension
1312
)
1413
from arcade.gl import Framebuffer
@@ -781,7 +780,7 @@ def use(self) -> None:
781780
self._ortho_projector.use()
782781

783782
@contextmanager
784-
def activate(self) -> Iterator[Projector]:
783+
def activate(self) -> Generator[Self, None, None]:
785784
"""
786785
Set internal projector as window projector,
787786
and set the projection and view matrix.
@@ -800,11 +799,15 @@ def activate(self) -> Iterator[Projector]:
800799
previous_framebuffer.use()
801800
previous_projection.use()
802801

803-
def map_screen_to_world_coordinate(
804-
self,
805-
screen_coordinate: Tuple[float, float],
806-
depth: Optional[float] = 0.0
807-
) -> Tuple[float, float]:
802+
def project(self, world_coordinate: Tuple[float, ...]) -> Tuple[float, float]:
803+
"""
804+
Take a Vec2 or Vec3 of coordinates and return the related screen coordinate
805+
"""
806+
return self._ortho_projector.project(world_coordinate)
807+
808+
def unproject(self,
809+
screen_coordinate: Tuple[float, float],
810+
depth: Optional[float] = None) -> Tuple[float, float, float]:
808811
"""
809812
Take in a pixel coordinate from within
810813
the range of the window size and returns
@@ -815,12 +818,22 @@ def map_screen_to_world_coordinate(
815818
Args:
816819
screen_coordinate: A 2D position in pixels from the bottom left of the screen.
817820
This should ALWAYS be in the range of 0.0 - screen size.
818-
depth: The depth value which is mapped along with the screen coordinates. Because of how
819-
Orthographic perspectives work this does not impact how the screen_coordinates are mapped.
821+
depth: The depth of the query
820822
Returns:
821-
A 2D vector (Along the XY plane) in world space (same as sprites).
823+
A 3D vector in world space (same as sprites).
822824
perfect for finding if the mouse overlaps with a sprite or ui element irrespective
823825
of the camera.
824826
"""
825827

826-
return self._ortho_projector.map_screen_to_world_coordinate(screen_coordinate, depth)[:2]
828+
return self._ortho_projector.unproject(screen_coordinate, depth)
829+
830+
def map_screen_to_world_coordinate(
831+
self,
832+
screen_coordinate: Tuple[float, float],
833+
depth: Optional[float] = None
834+
) -> Tuple[float, float, float]:
835+
"""
836+
Alias to Camera2D.unproject() for typing completion
837+
"""
838+
return self.unproject(screen_coordinate, depth)
839+

arcade/camera/data_types.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
wide usage throughout Arcade's camera code.
55
"""
66
from __future__ import annotations
7-
from typing import Protocol, Tuple, Iterator, Optional
7+
from typing import Protocol, Tuple, Iterator, Optional, Generator
88
from contextlib import contextmanager
99

10+
from typing_extensions import Self
1011
from pyglet.math import Vec3
1112

1213

@@ -184,18 +185,110 @@ def __repr__(self):
184185

185186

186187
class Projection(Protocol):
188+
"""Matches the data universal in Arcade's projection data objects.
189+
190+
There are multiple types of projections used in games, but all the
191+
common ones share key features. This :py:class:`~typing.Protocol`:
192+
193+
#. Defines those shared elements
194+
#. Annotates these in code for both humans and automated type
195+
checkers
196+
197+
The specific implementations which match it are used inside of
198+
implementations of Arcade's :py:class:`.Projector` behavior. All
199+
of these projectors rely on a ``viewport`` as well as ``near`` and
200+
``far`` values.
201+
202+
The ``viewport`` is measured in screen pixels. By default, the
203+
conventions for this are the same as the rest of Arcade and
204+
OpenGL:
205+
206+
* X is measured rightward from left of the screen
207+
* Y is measured up from the bottom of the screen
208+
209+
Although the ``near`` and ``far`` values are describe the cutoffs
210+
for what the camera sees in world space, the exact meaning differs
211+
between projection type.
212+
213+
.. list-table::
214+
:header-rows: 1
215+
216+
* - Common Projection Type
217+
- Meaning of ``near`` & ``far``
218+
219+
* - Simple Orthographic
220+
- The Z position in world space
221+
222+
* - Perspective & Isometric
223+
- Where the rear and front clipping planes sit along a
224+
camera's :py:attr:`.CameraData.forward` vector.
225+
226+
"""
187227
viewport: Tuple[int, int, int, int]
188228
near: float
189229
far: float
190230

191231

192232
class Projector(Protocol):
233+
"""Projects from world coordinates to viewport pixel coordinates.
234+
235+
Projectors also support converting in the opposite direction from
236+
screen pixel coordinates to world space coordinates.
237+
238+
The two key spatial methods which do this are:
239+
240+
.. list-table::
241+
:header-rows: 1
242+
* - Method
243+
- Action
244+
245+
* - :py:meth:`.project`
246+
- Turn world coordinates into pixel coordinates relative
247+
to the origin (bottom left by default).
248+
249+
* - :py:meth:`.unproject`
250+
- Convert screen pixel coordinates into world space.
251+
252+
.. note: Every :py:class:`.Camera` is also a kind of projector.
253+
254+
The other required methods are for helping manage which camera is
255+
currently used to draw.
256+
257+
"""
193258

194259
def use(self) -> None:
260+
"""Set the GL context to use this projector and its settings.
261+
262+
.. warning:: You may be looking for:py:meth:`.activate`!
263+
264+
This method only sets rendering state for a given
265+
projector. Since it doesn't restore any afterward,
266+
it's easy to misuse in ways which can cause bugs
267+
or temporarily break a game's rendering until
268+
relaunch. For reliable, automatic clean-up see
269+
the :py:meth:`.activate` method instead.
270+
271+
If you are implementing your own custom projector, this method
272+
should only:
273+
274+
#. Set the Arcade :py:class:`~arcade.Window`'s
275+
:py:attr:`~arcade.Window.current_camera` to this object
276+
#. Calculate any required view and projection matrices
277+
#. Set any resulting values on the current
278+
:py:class:`~arcade.context.ArcadeContext`, including the:
279+
280+
* :py:attr:`~arcade.context.ArcadeContext.viewport`
281+
* :py:attr:`~arcade.context.ArcadeContext.view_matrix`
282+
* :py:attr:`~arcade.context.ArcadeContext.projection_matrix`
283+
284+
This method should **never** handle cleanup. That is the
285+
responsibility of :py:attr:`.activate`.
286+
287+
"""
195288
...
196289

197290
@contextmanager
198-
def activate(self) -> Iterator[Projector]:
291+
def activate(self) -> Generator[Self, None, None]:
199292
...
200293

201294
def map_screen_to_world_coordinate(

0 commit comments

Comments
 (0)