Skip to content

Add a perspective projector #2052

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading