WUT_Computer_Science/Programming/TRAK/raytracer.py
Krzysztof kuhy Rudnicki 24f800d15a TRAK: zero findings, and a byte-identical render proving it
494 ruff findings to 0, and mypy --strict is clean too -- the first course in
this campaign where that is true of both.

Two real bugs turned up on the way, neither of which ruff can see:

- `main.py` passed `args.environment_map` to `ray_trace`, and argparse only
  ever defined `--environment`. Every `--algorithm ray_tracing0` run died with
  AttributeError before rendering a pixel, so `rendering.py` -- the largest
  file in the course -- had no way to be exercised at all.
- `mouse_button` in the environment-map viewer assigned `mouse_last_x/y`
  without declaring them `global`, so the drag origin went to locals and was
  thrown away. Measured against HEAD: a 10x5 px drag turned the camera by
  11.0/20.5 degrees because it measured from (0, 0); it is 1.0/0.5 now, which
  is what the 0.1 degrees-per-pixel constant says it should be.

Fixing the first one is what made the rest safe. `ray_tracing0` has no RNG
anywhere in it, so it renders byte-identically every time, and the whole
refactor of `rendering.py` is verified by one md5 comparison:
02484bdb6b6c1ddeff317d7e5566580f, 248619 rays, before and after. The
cornell_box path cannot do that -- its ray count drifts by ~20 between
identical runs.

Three files were over the 250-line cap and are now split, geometry away from
the algorithm that uses it: rendering.py 462 -> 137 + raytracer.py,
photon_mapping.py 261 -> 160 + photon_geometry.py, and
code/photonmapping/python/main.py 257 -> 203 + geometry.py. No TRAK source
file is over the cap now.

The two photon mappers do use numpy's legacy global RNG, so NPY002 could not
be bit-identical. Each was done in two steps: restructure first, verified
identical under an external seed (digests 7220370f60206a62 and
21b4771504ab2351, and an identical 828 photon traces), then the swap to
`default_rng` on its own, checked against the before/after distributions.

Verified by running, not by reading:

- cornell_box through `./run.sh run Programming/TRAK`: 24.27s, 13294 rays
- all five scenes build after `from sightpy import *` became explicit imports
- the standalone photon mapper end to end: 81.4s, 38742 photon traces
- the EXR loader byte for byte: a0c3890350ce6101247c6948, 6291456 bytes
- the GLUT viewer under xvfb-run, which drew a real 1024x512 frame

`pyproject.toml` gains a mypy override for Imath, OpenEXR, OpenGL and
matplotlib. Those four ship neither stubs nor py.typed, and without it mypy
cannot import them at all and so reports nothing else about the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:54:51 +02:00

234 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Ray/geometry intersection and Phong shading for the `ray_tracing0` renderer.
These were nested closures inside `rendering.ray_trace`, which is what made
that one function 460 lines long. They are pure now: the scene and the light
constants are passed in rather than captured, so each can be read on its own.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
# A scene object is a plain dict tagged with "type": "sphere" or "plane".
SceneObject = dict[str, Any]
# A ray whose direction is this close to parallel with a plane never hits it.
_PARALLEL_EPSILON = 1e-6
# Shadow and reflection rays start this far off the surface, so that the
# surface they just left does not intersect them at t=0.
SURFACE_OFFSET = 0.0001
@dataclass(frozen=True)
class Lighting:
"""The single point light and the Phong coefficients every surface uses."""
position: np.ndarray
color: np.ndarray
ambient: float
diffuse_c: float
specular_c: float
specular_k: int
def normalize(vector: np.ndarray) -> np.ndarray:
"""Scale `vector` to unit length, in place, and return it."""
vector /= np.linalg.norm(vector)
return vector
def intersect_plane(
ray_origin: np.ndarray,
ray_direction: np.ndarray,
plane_point: np.ndarray,
plane_normal: np.ndarray,
) -> float:
"""Distance along the ray to the plane, or +inf if it never reaches it."""
denom = np.dot(ray_direction, plane_normal)
if np.abs(denom) < _PARALLEL_EPSILON:
return np.inf
d = np.dot(plane_point - ray_origin, plane_normal) / denom
if d < 0:
return np.inf
return float(d)
def calculate_sphere_intersection(a: float, b: float, c: float, disc: float) -> float:
"""Solve `a*t^2 + b*t + c` for the nearest root ahead of the ray origin.
`disc` is the discriminant, passed in because the caller already has it.
The root is taken via the numerically stable `q` form rather than the
textbook quadratic formula, which loses precision when `b` dominates.
"""
if disc > 0:
distance_squared = np.sqrt(disc)
q = (-b - distance_squared) / 2.0 if b < 0 else (-b + distance_squared) / 2.0
t0 = q / a
t1 = c / q
t0, t1 = min(t0, t1), max(t0, t1)
if t1 >= 0:
return float(t1) if t0 < 0 else float(t0)
return np.inf
def intersect_sphere(
ray_origin: np.ndarray,
ray_direction: np.ndarray,
sphere_center: np.ndarray,
sphere_radius: float,
) -> float:
"""Distance along the ray to the sphere, or +inf if it misses."""
a = np.dot(ray_direction, ray_direction)
origin_to_center = ray_origin - sphere_center
b = 2 * np.dot(ray_direction, origin_to_center)
radius_squared = sphere_radius * sphere_radius
c = np.dot(origin_to_center, origin_to_center) - radius_squared
disc = b * b - 4 * a * c
return calculate_sphere_intersection(a, b, c, disc)
def intersect(
ray_origin: np.ndarray, ray_direction: np.ndarray, object_: SceneObject
) -> float:
"""Dispatch to the plane or sphere intersection for `object_`."""
if object_["type"] == "plane":
return intersect_plane(
ray_origin, ray_direction, object_["position"], object_["normal"]
)
return intersect_sphere(
ray_origin, ray_direction, object_["position"], object_["radius"]
)
def get_normal(object_: SceneObject, intersection_point: np.ndarray) -> np.ndarray:
"""Surface normal of `object_` at `intersection_point`."""
if object_["type"] == "sphere":
return normalize(intersection_point - object_["position"])
if object_["type"] == "plane":
return cast("np.ndarray", object_["normal"])
msg = f"Unknown object type: {object_['type']}"
raise ValueError(msg)
def get_color(object_: SceneObject, intersection_point: np.ndarray) -> np.ndarray:
"""Return the colour of `object_`: either a constant or a function of position."""
color = object_["color"]
if not hasattr(color, "__len__"):
color = color(intersection_point)
return cast("np.ndarray", color)
def find_intersection(
scene: list[SceneObject], ray_origin: np.ndarray, ray_direction: np.ndarray
) -> tuple[float, int]:
"""Return the distance to the nearest hit and that object's scene index.
The index is -1 when the ray hits nothing, in which case the distance is
+inf and callers stop.
"""
t = np.inf
obj_idx = -1
for index, object_ in enumerate(scene):
t_obj = intersect(ray_origin, ray_direction, object_)
if t_obj < t:
t, obj_idx = t_obj, index
return t, obj_idx
def is_shadowed(
scene: list[SceneObject],
lighting: Lighting,
intersection_point: np.ndarray,
normal: np.ndarray,
obj_idx: int,
) -> bool:
"""Report whether anything but object `obj_idx` blocks the light here."""
to_light = normalize(lighting.position - intersection_point)
shadow_intersections = [
intersect(intersection_point + normal * SURFACE_OFFSET, to_light, obj_sh)
for k, obj_sh in enumerate(scene)
if k != obj_idx
]
return bool(shadow_intersections) and min(shadow_intersections) < np.inf
def compute_color(
lighting: Lighting,
object_: SceneObject,
intersection_point: np.ndarray,
normal: np.ndarray,
ray_origin: np.ndarray,
) -> tuple[SceneObject, np.ndarray, np.ndarray, np.ndarray]:
"""Shade the hit with Blinn-Phong and return it alongside the hit geometry."""
color = get_color(object_, intersection_point)
to_light = normalize(lighting.position - intersection_point)
to_origin = normalize(ray_origin - intersection_point)
color_ray = lighting.ambient
diffuse_intensity = object_.get("diffuse_c", lighting.diffuse_c) * max(
np.dot(normal, to_light), 0
)
color_ray += diffuse_intensity * color
half_vector = normalize(to_light + to_origin)
specular_intensity = (
object_.get("specular_c", lighting.specular_c)
* max(np.dot(normal, half_vector), 0) ** lighting.specular_k
)
color_ray += specular_intensity * lighting.color
return object_, intersection_point, normal, color_ray
def trace_ray(
scene: list[SceneObject],
lighting: Lighting,
ray_origin: np.ndarray,
ray_direction: np.ndarray,
) -> tuple[SceneObject, np.ndarray, np.ndarray, np.ndarray] | None:
"""Trace one ray and return its hit, or None if it misses or lands in shadow."""
t, obj_idx = find_intersection(scene, ray_origin, ray_direction)
if t == np.inf:
return None
object_ = scene[obj_idx]
intersection_point = ray_origin + ray_direction * t
normal = get_normal(object_, intersection_point)
if is_shadowed(scene, lighting, intersection_point, normal, obj_idx):
return None
return compute_color(lighting, object_, intersection_point, normal, ray_origin)
def make_sphere(
position: list[float], radius: float, color: np.ndarray
) -> SceneObject:
"""Build a sphere scene object."""
return {
"type": "sphere",
"position": np.array(position),
"radius": np.array(radius),
"color": np.array(color),
"reflection": 0.5,
}
def make_plane(
position: list[float],
normal: list[float],
color_a: np.ndarray,
color_b: np.ndarray,
) -> SceneObject:
"""Build a plane scene object with a checkerboard of `color_a`/`color_b`."""
return {
"type": "plane",
"position": np.array(position),
"normal": np.array(normal),
"color": lambda point: (
color_a if (int(point[0] * 2) % 2) == (int(point[2] * 2) % 2) else color_b
),
"diffuse_c": 0.75,
"specular_c": 0.5,
"reflection": 0.25,
}