Skip to content

Commit

Permalink
Add a perspective projector (pythonarcade#2052)
Browse files Browse the repository at this point in the history
* re-adding perspective camera

* Creating a perspective camera example

* added map coords method and unit tests

* Fixing typing issue

* Doc String
  • Loading branch information
DragonMoffon authored Apr 7, 2024
1 parent e2ef4a9 commit 349fa82
Show file tree
Hide file tree
Showing 9 changed files with 395 additions and 7 deletions.
11 changes: 10 additions & 1 deletion arcade/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
Providing a multitude of camera's for any need.
"""

from arcade.camera.data_types import Projection, Projector, CameraData, OrthographicProjectionData
from arcade.camera.data_types import (
Projection,
Projector,
CameraData,
OrthographicProjectionData,
PerspectiveProjectionData
)

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

from arcade.camera.simple_camera import SimpleCamera
from arcade.camera.camera_2d import Camera2D
Expand All @@ -19,6 +26,8 @@
'CameraData',
'OrthographicProjectionData',
'OrthographicProjector',
'PerspectiveProjectionData',
'PerspectiveProjector',
'SimpleCamera',
'Camera2D',
'grips'
Expand Down
2 changes: 1 addition & 1 deletion arcade/camera/camera_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ def activate(self) -> Iterator[Projector]:
def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: float = 0.0
depth: Optional[float] = 0.0
) -> Tuple[float, float]:
"""
Take in a pixel coordinate from within
Expand Down
4 changes: 2 additions & 2 deletions arcade/camera/data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
wide usage throughout Arcade's camera code.
"""
from __future__ import annotations
from typing import Protocol, Tuple, Iterator
from typing import Protocol, Tuple, Iterator, Optional
from contextlib import contextmanager

from pyglet.math import Vec3
Expand Down Expand Up @@ -186,7 +186,7 @@ def activate(self) -> Iterator[Projector]:
def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: float = 0.0
depth: Optional[float] = None
) -> Tuple[float, ...]:
...

Expand Down
5 changes: 4 additions & 1 deletion arcade/camera/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ def activate(self) -> Iterator[Projector]:
finally:
previous.use()

def map_screen_to_world_coordinate(self, screen_coordinate: Tuple[float, float], depth=0.0) -> Tuple[float, float]:
def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: Optional[float] = 0.0) -> Tuple[float, float]:
"""
Map the screen pos to screen_coordinates.
Expand Down
4 changes: 3 additions & 1 deletion arcade/camera/orthographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def activate(self) -> Iterator[Projector]:
def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: float = 0.0
depth: Optional[float] = 0.0
) -> Tuple[float, float, float]:
"""
Take in a pixel coordinate from within
Expand All @@ -170,6 +170,8 @@ def map_screen_to_world_coordinate(
Returns:
A 3D vector in world space.
"""
depth = depth or 0.0

# TODO: Integrate z-depth
screen_x = 2.0 * (screen_coordinate[0] - self._projection.viewport[0]) / self._projection.viewport[2] - 1
screen_y = 2.0 * (screen_coordinate[1] - self._projection.viewport[1]) / self._projection.viewport[3] - 1
Expand Down
169 changes: 169 additions & 0 deletions arcade/camera/perspective.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
from typing import Optional, Tuple, Iterator, TYPE_CHECKING
from contextlib import contextmanager

from math import tan, radians
from pyglet.math import Mat4, Vec3, Vec4

from arcade.camera.data_types import Projector, CameraData, PerspectiveProjectionData

from arcade.window_commands import get_window
if TYPE_CHECKING:
from arcade import Window


__all__ = ("PerspectiveProjector",)


class PerspectiveProjector:
"""
The simplest from of a perspective camera.
Using ViewData and PerspectiveProjectionData PoDs (Pack of Data)
it generates the correct projection and view matrices. It also
provides methods and a context manager for using the matrices in
glsl shaders.
This class provides no methods for manipulating the PoDs.
The current implementation will recreate the view and
projection matrices every time the camera is used.
If used every frame or multiple times per frame this may
be inefficient. If you suspect this is causing slowdowns
profile before optimizing with a dirty value check.
Initialize a Projector which produces a perspective projection matrix using
a CameraData and PerspectiveProjectionData PoDs.
:param window: The window to bind the camera to. Defaults to the currently active camera.
:param view: The CameraData PoD. contains the viewport, position, up, forward, and zoom.
:param projection: The PerspectiveProjectionData PoD.
contains the field of view, aspect ratio, and then near and far planes.
"""

def __init__(self, *,
window: Optional["Window"] = None,
view: Optional[CameraData] = None,
projection: Optional[PerspectiveProjectionData] = None):
self._window: "Window" = window or get_window()

self._view = view or CameraData( # Viewport
(self._window.width / 2, self._window.height / 2, 0), # Position
(0.0, 1.0, 0.0), # Up
(0.0, 0.0, -1.0), # Forward
1.0 # Zoom
)

self._projection = projection or PerspectiveProjectionData(
self._window.width / self._window.height, # Aspect
60, # Field of View,
0.01, 100.0, # near, # far
(0, 0, self._window.width, self._window.height) # Viewport
)

@property
def view(self) -> CameraData:
"""
The CameraData. Is a read only property.
"""
return self._view

@property
def projection(self) -> PerspectiveProjectionData:
"""
The OrthographicProjectionData. Is a read only property.
"""
return self._projection

def _generate_projection_matrix(self) -> Mat4:
"""
Using the OrthographicProjectionData a projection matrix is generated where the size of the
objects is not affected by depth.
Generally keep the scale value to integers or negative powers of integers (2^-1, 3^-1, 2^-2, etc.) to keep
the pixels uniform in size. Avoid a zoom of 0.0.
"""
_proj = self._projection

return Mat4.perspective_projection(_proj.aspect, _proj.near, _proj.far, _proj.fov / self._view.zoom)

def _generate_view_matrix(self) -> Mat4:
"""
Using the ViewData it generates a view matrix from the pyglet Mat4 look at function
"""
# Even if forward and up are normalised floating point error means every vector must be normalised.
fo = Vec3(*self._view.forward).normalize() # Forward Vector
up = Vec3(*self._view.up) # Initial Up Vector (Not necessarily perpendicular to forward vector)
ri = fo.cross(up).normalize() # Right Vector
up = ri.cross(fo).normalize() # Up Vector
po = Vec3(*self._view.position)
return Mat4((
ri.x, up.x, -fo.x, 0,
ri.y, up.y, -fo.y, 0,
ri.z, up.z, -fo.z, 0,
-ri.dot(po), -up.dot(po), fo.dot(po), 1
))

def use(self) -> None:
"""
Sets the active camera to this object.
Then generates the view and projection matrices.
Finally, the gl context viewport is set, as well as the projection and view matrices.
"""

self._window.current_camera = self

_projection = self._generate_projection_matrix()
_view = self._generate_view_matrix()

self._window.ctx.viewport = self._projection.viewport
self._window.projection = _projection
self._window.view = _view

@contextmanager
def activate(self) -> Iterator[Projector]:
"""
A context manager version of OrthographicProjector.use() which allows for the use of
`with` blocks. For example, `with camera.activate() as cam: ...`.
"""
previous_projector = self._window.current_camera
try:
self.use()
yield self
finally:
previous_projector.use()

def map_screen_to_world_coordinate(
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
the world space coordinates.
Essentially reverses the effects of the projector.
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 of the query
Returns:
A 3D vector in world space.
"""
depth = depth or (0.5 * self._projection.viewport[3] / tan(
radians(0.5 * self._projection.fov / self._view.zoom)))

screen_x = 2.0 * (screen_coordinate[0] - self._projection.viewport[0]) / self._projection.viewport[2] - 1
screen_y = 2.0 * (screen_coordinate[1] - self._projection.viewport[1]) / self._projection.viewport[3] - 1

screen_x *= depth
screen_y *= depth

projected_position = Vec4(screen_x, screen_y, 1.0, 1.0)

_projection = ~self._generate_projection_matrix()
view_position = _projection @ projected_position
_view = ~self._generate_view_matrix()
world_position = _view @ Vec4(view_position.x, view_position.y, depth, 1.0)

return world_position.x, world_position.y, world_position.z
2 changes: 1 addition & 1 deletion arcade/camera/simple_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ def map_coordinate(self, screen_coordinate: Tuple[float, float], depth: float =
def map_screen_to_world_coordinate(
self,
screen_coordinate: Tuple[float, float],
depth: float = 0.0
depth: Optional[float] = 0.0
) -> Tuple[float, float]:
"""
Take in a pixel coordinate from within
Expand Down
56 changes: 56 additions & 0 deletions arcade/experimental/perspective_parallax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Perspective Parallax
Using a perspective projector and sprites at different depths you can cheaply get a parallax effect.
If Python and Arcade are installed, this example can be run from the command line with:
python -m arcade.examples.perspective_parallax
"""
import math

import arcade

SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
LAYERS = (
":assets:/images/cybercity_background/far-buildings.png",
":assets:/images/cybercity_background/back-buildings.png",
":assets:/images/cybercity_background/foreground.png",
)


class PerspectiveParallax(arcade.Window):

def __init__(self):
super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, "Perspective Parallax")
self.t = 0.0
self.camera = arcade.camera.PerspectiveProjector()

self.camera_data = self.camera.view
self.camera_data.zoom = 2.0

self.camera.projection.far = 1000

self.background_sprites = arcade.SpriteList()
for index, layer_src in enumerate(LAYERS):
layer = arcade.Sprite(layer_src)
layer.depth = -500 + index * 100.0
self.background_sprites.append(layer)

def on_draw(self):
self.clear()
with self.camera.activate():
self.background_sprites.draw(pixelated=True)

def on_update(self, delta_time: float):
self.t += delta_time

self.camera_data.position = (math.cos(self.t) * 200.0, math.sin(self.t) * 200.0, 0.0)


def main():
window = PerspectiveParallax()
window.run()


if __name__ == "__main__":
main()
Loading

0 comments on commit 349fa82

Please sign in to comment.