Skip to content

Commit 349fa82

Browse files
authored
Add a perspective projector (#2052)
* re-adding perspective camera * Creating a perspective camera example * added map coords method and unit tests * Fixing typing issue * Doc String
1 parent e2ef4a9 commit 349fa82

File tree

9 files changed

+395
-7
lines changed

9 files changed

+395
-7
lines changed

arcade/camera/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
Providing a multitude of camera's for any need.
44
"""
55

6-
from arcade.camera.data_types import Projection, Projector, CameraData, OrthographicProjectionData
6+
from arcade.camera.data_types import (
7+
Projection,
8+
Projector,
9+
CameraData,
10+
OrthographicProjectionData,
11+
PerspectiveProjectionData
12+
)
713

814
from arcade.camera.orthographic import OrthographicProjector
15+
from arcade.camera.perspective import PerspectiveProjector
916

1017
from arcade.camera.simple_camera import SimpleCamera
1118
from arcade.camera.camera_2d import Camera2D
@@ -19,6 +26,8 @@
1926
'CameraData',
2027
'OrthographicProjectionData',
2128
'OrthographicProjector',
29+
'PerspectiveProjectionData',
30+
'PerspectiveProjector',
2231
'SimpleCamera',
2332
'Camera2D',
2433
'grips'

arcade/camera/camera_2d.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ def activate(self) -> Iterator[Projector]:
725725
def map_screen_to_world_coordinate(
726726
self,
727727
screen_coordinate: Tuple[float, float],
728-
depth: float = 0.0
728+
depth: Optional[float] = 0.0
729729
) -> Tuple[float, float]:
730730
"""
731731
Take in a pixel coordinate from within

arcade/camera/data_types.py

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

1010
from pyglet.math import Vec3
@@ -186,7 +186,7 @@ def activate(self) -> Iterator[Projector]:
186186
def map_screen_to_world_coordinate(
187187
self,
188188
screen_coordinate: Tuple[float, float],
189-
depth: float = 0.0
189+
depth: Optional[float] = None
190190
) -> Tuple[float, ...]:
191191
...
192192

arcade/camera/default.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ def activate(self) -> Iterator[Projector]:
7575
finally:
7676
previous.use()
7777

78-
def map_screen_to_world_coordinate(self, screen_coordinate: Tuple[float, float], depth=0.0) -> Tuple[float, float]:
78+
def map_screen_to_world_coordinate(
79+
self,
80+
screen_coordinate: Tuple[float, float],
81+
depth: Optional[float] = 0.0) -> Tuple[float, float]:
7982
"""
8083
Map the screen pos to screen_coordinates.
8184

arcade/camera/orthographic.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def activate(self) -> Iterator[Projector]:
154154
def map_screen_to_world_coordinate(
155155
self,
156156
screen_coordinate: Tuple[float, float],
157-
depth: float = 0.0
157+
depth: Optional[float] = 0.0
158158
) -> Tuple[float, float, float]:
159159
"""
160160
Take in a pixel coordinate from within
@@ -170,6 +170,8 @@ def map_screen_to_world_coordinate(
170170
Returns:
171171
A 3D vector in world space.
172172
"""
173+
depth = depth or 0.0
174+
173175
# TODO: Integrate z-depth
174176
screen_x = 2.0 * (screen_coordinate[0] - self._projection.viewport[0]) / self._projection.viewport[2] - 1
175177
screen_y = 2.0 * (screen_coordinate[1] - self._projection.viewport[1]) / self._projection.viewport[3] - 1

arcade/camera/perspective.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from typing import Optional, Tuple, Iterator, TYPE_CHECKING
2+
from contextlib import contextmanager
3+
4+
from math import tan, radians
5+
from pyglet.math import Mat4, Vec3, Vec4
6+
7+
from arcade.camera.data_types import Projector, CameraData, PerspectiveProjectionData
8+
9+
from arcade.window_commands import get_window
10+
if TYPE_CHECKING:
11+
from arcade import Window
12+
13+
14+
__all__ = ("PerspectiveProjector",)
15+
16+
17+
class PerspectiveProjector:
18+
"""
19+
The simplest from of a perspective camera.
20+
Using ViewData and PerspectiveProjectionData PoDs (Pack of Data)
21+
it generates the correct projection and view matrices. It also
22+
provides methods and a context manager for using the matrices in
23+
glsl shaders.
24+
25+
This class provides no methods for manipulating the PoDs.
26+
27+
The current implementation will recreate the view and
28+
projection matrices every time the camera is used.
29+
If used every frame or multiple times per frame this may
30+
be inefficient. If you suspect this is causing slowdowns
31+
profile before optimizing with a dirty value check.
32+
33+
Initialize a Projector which produces a perspective projection matrix using
34+
a CameraData and PerspectiveProjectionData PoDs.
35+
36+
:param window: The window to bind the camera to. Defaults to the currently active camera.
37+
:param view: The CameraData PoD. contains the viewport, position, up, forward, and zoom.
38+
:param projection: The PerspectiveProjectionData PoD.
39+
contains the field of view, aspect ratio, and then near and far planes.
40+
"""
41+
42+
def __init__(self, *,
43+
window: Optional["Window"] = None,
44+
view: Optional[CameraData] = None,
45+
projection: Optional[PerspectiveProjectionData] = None):
46+
self._window: "Window" = window or get_window()
47+
48+
self._view = view or CameraData( # Viewport
49+
(self._window.width / 2, self._window.height / 2, 0), # Position
50+
(0.0, 1.0, 0.0), # Up
51+
(0.0, 0.0, -1.0), # Forward
52+
1.0 # Zoom
53+
)
54+
55+
self._projection = projection or PerspectiveProjectionData(
56+
self._window.width / self._window.height, # Aspect
57+
60, # Field of View,
58+
0.01, 100.0, # near, # far
59+
(0, 0, self._window.width, self._window.height) # Viewport
60+
)
61+
62+
@property
63+
def view(self) -> CameraData:
64+
"""
65+
The CameraData. Is a read only property.
66+
"""
67+
return self._view
68+
69+
@property
70+
def projection(self) -> PerspectiveProjectionData:
71+
"""
72+
The OrthographicProjectionData. Is a read only property.
73+
"""
74+
return self._projection
75+
76+
def _generate_projection_matrix(self) -> Mat4:
77+
"""
78+
Using the OrthographicProjectionData a projection matrix is generated where the size of the
79+
objects is not affected by depth.
80+
81+
Generally keep the scale value to integers or negative powers of integers (2^-1, 3^-1, 2^-2, etc.) to keep
82+
the pixels uniform in size. Avoid a zoom of 0.0.
83+
"""
84+
_proj = self._projection
85+
86+
return Mat4.perspective_projection(_proj.aspect, _proj.near, _proj.far, _proj.fov / self._view.zoom)
87+
88+
def _generate_view_matrix(self) -> Mat4:
89+
"""
90+
Using the ViewData it generates a view matrix from the pyglet Mat4 look at function
91+
"""
92+
# Even if forward and up are normalised floating point error means every vector must be normalised.
93+
fo = Vec3(*self._view.forward).normalize() # Forward Vector
94+
up = Vec3(*self._view.up) # Initial Up Vector (Not necessarily perpendicular to forward vector)
95+
ri = fo.cross(up).normalize() # Right Vector
96+
up = ri.cross(fo).normalize() # Up Vector
97+
po = Vec3(*self._view.position)
98+
return Mat4((
99+
ri.x, up.x, -fo.x, 0,
100+
ri.y, up.y, -fo.y, 0,
101+
ri.z, up.z, -fo.z, 0,
102+
-ri.dot(po), -up.dot(po), fo.dot(po), 1
103+
))
104+
105+
def use(self) -> None:
106+
"""
107+
Sets the active camera to this object.
108+
Then generates the view and projection matrices.
109+
Finally, the gl context viewport is set, as well as the projection and view matrices.
110+
"""
111+
112+
self._window.current_camera = self
113+
114+
_projection = self._generate_projection_matrix()
115+
_view = self._generate_view_matrix()
116+
117+
self._window.ctx.viewport = self._projection.viewport
118+
self._window.projection = _projection
119+
self._window.view = _view
120+
121+
@contextmanager
122+
def activate(self) -> Iterator[Projector]:
123+
"""
124+
A context manager version of OrthographicProjector.use() which allows for the use of
125+
`with` blocks. For example, `with camera.activate() as cam: ...`.
126+
"""
127+
previous_projector = self._window.current_camera
128+
try:
129+
self.use()
130+
yield self
131+
finally:
132+
previous_projector.use()
133+
134+
def map_screen_to_world_coordinate(
135+
self,
136+
screen_coordinate: Tuple[float, float],
137+
depth: Optional[float] = None
138+
) -> Tuple[float, float, float]:
139+
"""
140+
Take in a pixel coordinate from within
141+
the range of the window size and returns
142+
the world space coordinates.
143+
144+
Essentially reverses the effects of the projector.
145+
146+
Args:
147+
screen_coordinate: A 2D position in pixels from the bottom left of the screen.
148+
This should ALWAYS be in the range of 0.0 - screen size.
149+
depth: The depth of the query
150+
Returns:
151+
A 3D vector in world space.
152+
"""
153+
depth = depth or (0.5 * self._projection.viewport[3] / tan(
154+
radians(0.5 * self._projection.fov / self._view.zoom)))
155+
156+
screen_x = 2.0 * (screen_coordinate[0] - self._projection.viewport[0]) / self._projection.viewport[2] - 1
157+
screen_y = 2.0 * (screen_coordinate[1] - self._projection.viewport[1]) / self._projection.viewport[3] - 1
158+
159+
screen_x *= depth
160+
screen_y *= depth
161+
162+
projected_position = Vec4(screen_x, screen_y, 1.0, 1.0)
163+
164+
_projection = ~self._generate_projection_matrix()
165+
view_position = _projection @ projected_position
166+
_view = ~self._generate_view_matrix()
167+
world_position = _view @ Vec4(view_position.x, view_position.y, depth, 1.0)
168+
169+
return world_position.x, world_position.y, world_position.z

arcade/camera/simple_camera.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ def map_coordinate(self, screen_coordinate: Tuple[float, float], depth: float =
371371
def map_screen_to_world_coordinate(
372372
self,
373373
screen_coordinate: Tuple[float, float],
374-
depth: float = 0.0
374+
depth: Optional[float] = 0.0
375375
) -> Tuple[float, float]:
376376
"""
377377
Take in a pixel coordinate from within
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Perspective Parallax
3+
4+
Using a perspective projector and sprites at different depths you can cheaply get a parallax effect.
5+
6+
If Python and Arcade are installed, this example can be run from the command line with:
7+
python -m arcade.examples.perspective_parallax
8+
"""
9+
import math
10+
11+
import arcade
12+
13+
SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
14+
LAYERS = (
15+
":assets:/images/cybercity_background/far-buildings.png",
16+
":assets:/images/cybercity_background/back-buildings.png",
17+
":assets:/images/cybercity_background/foreground.png",
18+
)
19+
20+
21+
class PerspectiveParallax(arcade.Window):
22+
23+
def __init__(self):
24+
super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, "Perspective Parallax")
25+
self.t = 0.0
26+
self.camera = arcade.camera.PerspectiveProjector()
27+
28+
self.camera_data = self.camera.view
29+
self.camera_data.zoom = 2.0
30+
31+
self.camera.projection.far = 1000
32+
33+
self.background_sprites = arcade.SpriteList()
34+
for index, layer_src in enumerate(LAYERS):
35+
layer = arcade.Sprite(layer_src)
36+
layer.depth = -500 + index * 100.0
37+
self.background_sprites.append(layer)
38+
39+
def on_draw(self):
40+
self.clear()
41+
with self.camera.activate():
42+
self.background_sprites.draw(pixelated=True)
43+
44+
def on_update(self, delta_time: float):
45+
self.t += delta_time
46+
47+
self.camera_data.position = (math.cos(self.t) * 200.0, math.sin(self.t) * 200.0, 0.0)
48+
49+
50+
def main():
51+
window = PerspectiveParallax()
52+
window.run()
53+
54+
55+
if __name__ == "__main__":
56+
main()

0 commit comments

Comments
 (0)