from time import perf_counter_ns
import numpy as np
import pylinalg as la
from ..objects._base import WorldObject
from ..utils.transform import cached
[docs]
class Camera(WorldObject):
"""Abstract base camera.
Camera's are world objects and can be placed in the scene, but this is not required.
The purpose of a camera is to define the viewpoint for rendering a scene.
This viewpoint consists of its position and orientation (in the world) and
its projection.
In other words, it covers the projection of world coordinates to
normalized device coordinates (NDC), by the (inverse of) the
camera's own world matrix and the camera's projection transform.
The former represent the camera's position, the latter is specific
to the type of camera.
"""
_FORWARD_IS_MINUS_Z = True
[docs]
def __init__(self):
super().__init__()
self._last_modified = perf_counter_ns()
self._view_size = 1.0, 1.0
self._view_offset = None
[docs]
def flag_update(self):
self._last_modified = perf_counter_ns()
@property
def last_modified(self) -> int:
return max(self._last_modified, self.world.last_modified)
[docs]
def set_view_size(self, width, height):
"""Sets the logical size of the target. Set by the renderer; you should typically not use this."""
self._view_size = float(width), float(height)
self.flag_update()
[docs]
def set_view_offset(
self,
full_width: float,
full_height: float,
x: float,
y: float,
width: float,
height: float,
):
"""Set the offset in a larger viewing frustrum and override the logical size.
This is useful for advanced use-cases such as multi-window setups or taking tiled screenshots.
It is the responsibility of the caller to make sure that the ratio of the ``width`` and ``height``
match that of the canvas/viewport being rendered to, so that the effective ``pixel_ratio`` is isotropic.
.. code-block:: python
# Assuming a canvas with a logical size of 640x480 ...
# Use a custom logical size
camera.set_view_offset(320, 240, 0, 0, 320, 240)
# Render the bottom-left corner, sizes in screen-space become larger (relative to the screen)
camera.set_view_offset(640, 480, 0, 240, 320, 240)
# Render the bottom-left corner, sizes in screen-space stay the same (relative to the screen)
camera.set_view_offset(1280, 960, 0, 480, 640, 480)
Parameters
----------
full_width (float): The full width of the virtual viewing frustrum.
full_height (float): The full height of the virtual viewing frustrum.
x (float): The horizontal offset of the curent sub-view.
y (float): The vertical offset of the curent sub-view.
width (float): The width of the current sub-view.
height (float): The height of the current sub-view.
"""
# Store values
self._view_offset = vo = {
"full_width": float(full_width),
"full_height": float(full_height),
"x": float(x),
"y": float(y),
"width": float(width),
"height": float(height),
}
# Calculate ndc_offset, a value that can be easily applied in the shader using
# virtual_ndc = ndc.xy * ndc_offset.xy + ndc_offset.zw
ax = vo["width"] / vo["full_width"]
ay = vo["height"] / vo["full_height"]
self._view_offset["ndc_offset"] = (
ax,
ay,
ax + 2.0 * vo["x"] / vo["full_width"] - 1.0,
-(ay + 2.0 * vo["y"] / vo["full_height"] - 1.0),
)
self.flag_update()
[docs]
def clear_view_offset(self):
"""Remove the currently set view offset, returning to a normal view."""
self._view_offset = None
self.flag_update()
def _update_projection_matrix(self) -> np.ndarray:
raise NotImplementedError()
[docs]
def get_state(self):
"""Get the state of the camera as a dict."""
return {}
[docs]
def set_state(self, state):
"""Set the state of the camera from a dict."""
self.flag_update()
@property
def view_matrix(self) -> np.ndarray:
return self.world.inverse_matrix
@cached
def projection_matrix(self) -> np.ndarray:
base = self._update_projection_matrix()
if self._view_offset is None:
return base
view_offset = self._view_offset
s_x = view_offset["full_width"] / view_offset["width"]
s_y = view_offset["full_height"] / view_offset["height"]
d_x = view_offset["x"] / view_offset["full_width"]
d_y = view_offset["y"] / view_offset["full_height"]
t_x = +(s_x - 1.0 - 2.0 * s_x * d_x)
t_y = -(s_y - 1.0 - 2.0 * s_y * d_y)
ndc_matrix = np.array(
[
[s_x, 0.0, 0.0, t_x],
[0.0, s_y, 0.0, t_y],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
np.float32,
)
proj_matrix = ndc_matrix @ base
proj_matrix.flags.writeable = False
return proj_matrix
@cached
def projection_matrix_inverse(self) -> np.ndarray:
proj_inv_matrix = la.mat_inverse(self.projection_matrix)
proj_inv_matrix.flags.writeable = False
return proj_inv_matrix
@cached
def camera_matrix(self) -> np.ndarray:
cam_matrix = self.projection_matrix @ self.view_matrix
cam_matrix.flags.writeable = False
return cam_matrix
class NDCCamera(Camera):
"""A Camera operating in NDC coordinates.
Its projection matrix is the identity transform (but its position and rotation can still be set).
In the NDC coordinate system of wgpu (and Pygfx), x and y are in
the range -1..1, z is in the range 0..1, and (-1, -1, 0) represents
the bottom left corner.
"""
def __init__(self):
super().__init__()
self._ndc_proj_matrix = np.eye(4, dtype=float)
self._ndc_proj_matrix.flags.writeable = False
def _update_projection_matrix(self):
return self._ndc_proj_matrix
class ScreenCoordsCamera(Camera):
"""A Camera operating in screen coordinates.
The depth range is the same as in NDC (0 to 1).
"""
def __init__(self, invert_y=False):
super().__init__()
self._invert_y = bool(invert_y)
def _update_projection_matrix(self):
width, height = self._view_size
sx, sy, sz = 2 / width, 2 / height, 1
dx, dy, dz = -1, -1, 0
if self._invert_y:
dy = -dy
sy = -sy
m = sx, 0, 0, dx, 0, sy, 0, dy, 0, 0, sz, dz, 0, 0, 0, 1
proj_matrix = np.array(m, dtype=float).reshape(4, 4)
proj_matrix.flags.writeable = False
return proj_matrix