"""Rays: the ``Ray`` value type and ray-sphere intersection."""
import math
from dataclasses import dataclass
import numpy as np
from .types import Vec3
[docs]
@dataclass(frozen=True, slots=True, init=False, eq=False)
class Ray:
"""A half-line in world space: an origin and a unit direction.
``Camera3D.ray_at(screen_pos)`` is the usual way to get one; build one by
hand with ``Ray(origin, direction)``, which normalises the direction.
Distances along a ray are in world units, the same ``distance`` a physics
``RayHit`` reports, so ``ray.at(hit.distance)`` is the hit point.
Example::
ray = camera.ray_at(Input.mouse_position)
ground = ray.intersect_plane((0, 0, 0), (0, 1, 0))
if ground is not None:
marker.position = ground
"""
origin: Vec3
direction: Vec3
def __init__(self, origin, direction):
d = np.asarray(direction, dtype=np.float32)
length = float(np.linalg.norm(d))
if not length > 0.0:
raise ValueError(f"Ray direction must be a non-zero vector, got {tuple(d.tolist())}")
object.__setattr__(self, "origin", Vec3(origin))
object.__setattr__(self, "direction", Vec3(d / length))
[docs]
@classmethod
def from_screen(cls, screen_pos, screen_size, view, proj) -> Ray:
"""The ray through *screen_pos* for a camera given as matrices.
For a view drawn by a camera node, ``Camera3D.ray_at`` is the spelling
to use; this form is for a view whose size or matrices do not come from
a node, such as a viewport panel inside a larger window.
Args:
screen_pos: Pixel position, ``(0, 0)`` at the top left of the view.
screen_size: The view's ``(width, height)`` in the same units.
view: Row-major 4x4 view matrix.
proj: Row-major 4x4 reverse-Z projection matrix, Vulkan Y-flip
included or not (the flip is read from the matrix).
"""
ndc_x = 2.0 * float(screen_pos[0]) / float(screen_size[0]) - 1.0
ndc_y = 1.0 - 2.0 * float(screen_pos[1]) / float(screen_size[1])
view = np.asarray(view, dtype=np.float32).reshape(4, 4)
proj = np.asarray(proj, dtype=np.float32).reshape(4, 4)
if proj[1, 1] < 0:
ndc_y = -ndc_y # Vulkan Y-flip
# Both projections are reverse-Z: the near plane is NDC z = 1 and the far
# plane z = 0. Under perspective the choice of near z only slides the origin
# along a ray that passes through the eye either way; under orthographic the
# rays are parallel, so reading the far value would place every origin a
# whole depth range behind the camera and offset every hit distance.
inv_vp = np.linalg.inv(proj @ view)
near = inv_vp @ np.array([ndc_x, ndc_y, 1.0, 1.0], dtype=np.float32)
far = inv_vp @ np.array([ndc_x, ndc_y, 0.0, 1.0], dtype=np.float32)
near_pos = near[:3] / near[3]
return cls(near_pos, far[:3] / far[3] - near_pos)
[docs]
def at(self, distance: float) -> Vec3:
"""The point *distance* world units along the ray."""
return Vec3(self.origin + self.direction * float(distance))
[docs]
def intersect_plane(self, point, normal) -> Vec3 | None:
"""Where the ray crosses the plane through *point* with *normal*, or ``None``.
``None`` when the ray runs parallel to the plane or the plane is behind
the origin. Either side of the plane counts, so a ray from below a floor
still meets it.
"""
n = np.asarray(normal, dtype=np.float32)
denom = float(np.dot(self.direction, n))
if abs(denom) < 1e-8:
return None
t = float(np.dot(np.asarray(point, dtype=np.float32) - self.origin, n)) / denom
return self.at(t) if t >= 0.0 else None
[docs]
def ray_intersect_sphere(origin, direction, center, radius: float):
"""Ray-sphere intersection. Returns distance t or None if no hit."""
if not isinstance(origin, Vec3):
origin = Vec3(origin)
if not isinstance(direction, Vec3):
direction = Vec3(direction)
if not isinstance(center, Vec3):
center = Vec3(center)
oc = origin - center
b = oc.dot(direction)
c = oc.dot(oc) - radius * radius
discriminant = b * b - c
if discriminant < 0:
return None
sqrt_d = math.sqrt(discriminant)
t = -b - sqrt_d
if t < 0:
t = -b + sqrt_d
return t if t >= 0 else None