"""Packets of data and base types supporting cameras.
These are placed in their own module to simplify imports due to their
wide usage throughout Arcade's camera code.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Final, Generator, Protocol
from pyglet.math import Vec2, Vec3
from typing_extensions import Self
from arcade.types import LRBT, AsFloat, Point, Point3, Rect
__all__ = [
"CameraData",
"DEFAULT_FAR",
"DEFAULT_NEAR_ORTHO",
"OrthographicProjectionData",
"PerspectiveProjectionData",
"Projection",
"Projector",
"ZeroProjectionDimension",
"constrain_camera_data",
"duplicate_camera_data",
]
DEFAULT_NEAR_ORTHO: Final[float] = -100.0
"""The default backward-facing depth cutoff for orthographic rendering.
Unless an orthographic camera is provided a different value, this will be
used as the near cutoff for its point of view.
The :py:class:`~arcade.camera.perspective.PerspectiveProjector` uses
``0.01`` as its default near value to avoid division by zero.
"""
DEFAULT_FAR: Final[float] = 100.0
"""The default forward-facing depth cutoff for all Arcade cameras.
Unless a camera is provided a different value, anything further away than this
value will not be drawn.
"""
[docs]
class ZeroProjectionDimension(ValueError):
"""A projection's dimensions were zero along at least one axis.
This usually happens because code tried to set one of the following:
* ``left`` equal to ``right``
* ``bottom`` equal to ``top``
You can handle this error as a :py:class:`ValueError`.
"""
...
[docs]
class CameraData:
"""Stores position, orientation, and zoom for a camera.
This is like where a camera is placed in 3D space.
Args:
position:
The camera's location in 3D space.
up:
The direction which is considered "up" for the camera.
forward:
The direction the camera is facing.
zoom:
How much the camera is zoomed in or out.
"""
__slots__ = ("position", "up", "forward", "zoom")
def __init__(
self,
position: Point3 = (0.0, 0.0, 0.0),
up: Point3 = (0.0, 1.0, 0.0),
forward: Point3 = (0.0, 0.0, -1.0),
zoom: float = 1.0,
):
self.position: tuple[float, float, float] = position
"""A 3D vector which describes where the camera is located."""
self.up: tuple[float, float, float] = up
"""A 3D vector which describes which direction is up (+y)."""
self.forward: tuple[float, float, float] = forward
"""
A scalar which describes which direction the camera is pointing.
While this affects the projection matrix, it also allows camera
controllers to access zoom functionality without interacting with
projection data.
"""
self.zoom: float = zoom
"""A scalar which describes how much the camera is zoomed in or out."""
def __str__(self):
return f"CameraData<{self.position=}, {self.up=}, {self.forward=}, {self.zoom=}>"
def __repr__(self):
return self.__str__()
[docs]
def duplicate_camera_data(origin: CameraData):
"""
Clone camera data
Args:
origin: The camera data to clone
"""
return CameraData(origin.position, origin.up, origin.forward, float(origin.zoom))
[docs]
def constrain_camera_data(data: CameraData, forward_priority: bool = False):
"""
Ensure that the camera data forward and up vectors are length one,
and are perpendicular
Args:
data: the camera data to constrain
forward_priority: whether up or forward gets constrained
"""
forward_vec = Vec3(*data.forward).normalize()
up_vec = Vec3(*data.up).normalize()
right_vec = forward_vec.cross(up_vec).normalize()
if forward_priority:
up_vec = right_vec.cross(forward_vec)
else:
forward_vec = up_vec.cross(right_vec)
data.forward = (forward_vec.x, forward_vec.y, forward_vec.z)
data.up = (up_vec.x, up_vec.y, up_vec.z)
[docs]
class OrthographicProjectionData:
"""Describes an Orthographic projection.
This is by default a Left-handed system. with the X axis going from left to
right, The Y axis going from bottom to top, and the Z axis going from towards
the screen to away from the screen. This can be made right-handed by making
the near value greater than the far value.
Args:
left: Left limit of the projection
right: Right limit of the projection
bottom: Bottom limit of the projection
top: Top limit of the projection
near: Near plane
far: Far plane
"""
__slots__ = ("rect", "near", "far")
def __init__(
self, left: float, right: float, bottom: float, top: float, near: float, far: float
):
self.rect: Rect = LRBT(left, right, bottom, top)
"""Rectangle defining the projection area."""
self.near: float = near
"""
The 'closest' visible position along the forward direction.
It will get mapped to z = -1.0. Anything closer than this value
is not visible.
"""
self.far: float = far
"""
The 'farthest' visible position along the forward direction.
It will get mapped to z = 1.0. Anything father than this value
is not visible.
"""
@property
def left(self) -> float:
"""
The left-side cutoff value, which gets mapped to x = -1.0.
Anything to the left of this value is not visible.
"""
return self.rect.left
@left.setter
def left(self, new_left: AsFloat):
r = self.rect
dl = new_left - r.left
self.rect = Rect(
new_left, r.right, r.bottom, r.top, r.width + dl, r.height, r.x + dl / 2.0, r.y
)
@property
def right(self) -> float:
"""
The right-side cutoff value, which gets mapped to x = 1.0.
Anything to the left of this value is not visible.
"""
return self.rect.right
@right.setter
def right(self, new_right: AsFloat):
r = self.rect
dr = new_right - r.right
self.rect = Rect(
r.left, new_right, r.bottom, r.top, r.width + dr, r.height, r.x + dr / 2.0, r.y
)
@property
def bottom(self) -> float:
"""
The bottom-side cutoff value, which gets mapped to -y = 1.0.
Anything to the left of this value is not visible.
"""
return self.rect.bottom
@bottom.setter
def bottom(self, new_bottom: AsFloat):
r = self.rect
db = new_bottom - r.bottom
self.rect = Rect(
r.left, r.right, new_bottom, r.top, r.width, r.height + db, r.x, r.y + db / 2.0
)
@property
def top(self) -> float:
"""
The top-side cutoff value, which gets mapped to y = 1.0.
Anything to the left of this value is not visible.
"""
return self.rect.top
@top.setter
def top(self, new_top: AsFloat):
r = self.rect
dt = new_top - r.top
self.rect = Rect(
r.left, r.right, r.bottom, new_top, r.width, r.height + dt, r.x, r.y + dt / 2.0
)
@property
def lrbt(self) -> tuple[float, float, float, float]:
"""The left, right, bottom, and top values of the projection."""
return self.rect.lrbt
@lrbt.setter
def lrbt(self, new_lrbt: tuple[float, float, float, float]):
self.rect = LRBT(*new_lrbt)
def __str__(self):
return (
f"OrthographicProjection<" f"LRBT={self.rect.lrbt}, " f"{self.near=}, " f"{self.far=}"
)
def __repr__(self):
return self.__str__()
[docs]
def orthographic_from_rect(rect: Rect, near: float, far: float) -> OrthographicProjectionData:
"""
Create an orthographic projection from a rectangle.
Args:
rect: The rectangle to create the projection from.
near: The near plane of the projection.
far: The far plane of the projection.
"""
return OrthographicProjectionData(rect.left, rect.right, rect.bottom, rect.top, near, far)
[docs]
class PerspectiveProjectionData:
"""
Data for perspective projection.
Args:
aspect: The aspect ratio of the screen (width over height).
fov: The field of view in degrees.
near: The 'closest' visible position along the forward direction.
far: The 'farthest' visible position along the forward
"""
__slots__ = ("aspect", "fov", "near", "far")
def __init__(self, aspect: float, fov: float, near: float, far: float):
self.aspect: float = aspect
"""The aspect ratio of the screen (width over height)."""
self.fov: float = fov
"""
The field of view in degrees.
Together with the aspect ratio, it defines the size of the
perspective projection for any given depth.
"""
self.near: float = near
"""
The 'closest' visible position along the forward direction.
It will get mapped to z = -1.0. Anything closer than this value
is not visible.
"""
self.far: float = far
""""
The 'farthest' visible position along the forward direction.
It will get mapped to z = 1.0. Anything father than this value
is not visible.
"""
def __str__(self) -> str:
return f"PerspectiveProjection<{self.aspect=}, {self.fov=}, {self.near=}, {self.far=}>"
def __repr__(self) -> str:
return self.__str__()
[docs]
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.
"""
near: float
far: float
[docs]
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.
"""
[docs]
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`.
"""
...
[docs]
@contextmanager
def activate(self) -> Generator[Self, None, None]: ...
[docs]
def project(self, world_coordinate: Point) -> Vec2:
"""
Take a Vec2 or Vec3 of coordinates and return the related screen coordinate
"""
...
[docs]
def unproject(self, screen_coordinate: Point) -> Vec3:
"""
Take in a pixel coordinate and return the associated world coordinate
Essentially reverses the effects of the projector.
Args:
screen_coordinate: A 2D position in pixels should generally be inside
the range of the active viewport.
Returns:
A 3D vector in world space.
"""
...