Raycast Rendering
Source: raycast_render.py
Ray-traced rendering of a mesh using an orthographic camera. Generates a grid of rays (one per pixel), batch ray_casts them against the mesh in a single call, and renders Lambertian shading from face normals.
python raycast_render.py [mesh.stl] [--resolution 512]
Features Showcased
- Batch
tf.Rayconstruction from numpy arrays - Batch
tf.ray_castreturning(element_ids, t_values)per ray - Looking up
mesh.normalsby hit face ID - Two-light Lambertian shading with rim lighting
Loading the Mesh
import numpy as np
import trueform as tf
faces, points = tf.read_stl("dragon.stl")
mesh = tf.Mesh(faces, points)
mesh.build_tree()
Setting Up the Camera
An orthographic camera looking down the -Z axis. The ray grid spans the mesh bounding box with 10% padding.
aabb_min = points.min(axis=0)
aabb_max = points.max(axis=0)
size = aabb_max - aabb_min
pad = size.max() * 0.1
res = 512
x = np.linspace(aabb_min[0] - pad, aabb_max[0] + pad, res).astype(points.dtype)
y = np.linspace(aabb_max[1] + pad, aabb_min[1] - pad, res).astype(points.dtype)
gx, gy = np.meshgrid(x, y)
n_rays = res * res
origins = np.zeros((n_rays, 3), dtype=points.dtype)
origins[:, 0] = gx.ravel()
origins[:, 1] = gy.ravel()
origins[:, 2] = aabb_max[2] + pad
directions = np.zeros((n_rays, 3), dtype=points.dtype)
directions[:, 2] = -1.0
Batch Ray Casting
Construct a batch of rays and cast them all against the mesh in a single call. The result is a tuple of (element_ids, t_values) — one entry per ray. Misses have -1 for the element ID and NaN for t.
rays = tf.Ray(origin=origins, direction=directions)
ids, ts = tf.ray_cast(rays, mesh)
ids = ids.reshape(res, res)
ts = ts.reshape(res, res)
hit = ~np.isnan(ts)
tf.ray_cast call. The C++ backend parallelizes automatically for batches above 1,000 rays.Shading
Look up face normals by hit element ID. Two directional lights (key + fill) provide Lambertian diffuse shading, and a Fresnel-like rim term adds bright edges where the surface is nearly perpendicular to the camera.
normals = mesh.normals
hit_ids = ids[hit]
hit_normals = normals[hit_ids].copy()
# Flip back-facing normals
hit_normals[hit_normals[:, 2] > 0] *= -1
# Key light + fill light
key_dir = np.array([0.4, 0.6, -0.7], dtype=np.float32)
key_dir /= np.linalg.norm(key_dir)
fill_dir = np.array([-0.5, -0.3, -0.6], dtype=np.float32)
fill_dir /= np.linalg.norm(fill_dir)
key = np.clip(hit_normals @ key_dir, 0.0, 1.0)
fill = np.clip(hit_normals @ fill_dir, 0.0, 1.0)
diffuse = 0.08 + 0.72 * key + 0.20 * fill
# Rim lighting
view_dir = np.array([0.0, 0.0, -1.0], dtype=np.float32)
facing = np.abs(hit_normals @ view_dir)
rim = (1.0 - facing) ** 3
intensity = diffuse + 0.35 * rim
Compositing the Image
Map intensity to a teal color ramp on a dark background and display with matplotlib.
import matplotlib.pyplot as plt
BG = np.array([27, 43, 52], dtype=np.float32) / 255
TEAL = np.array([0.0, 0.659, 0.604])
TEAL_BRIGHT = np.array([0.0, 0.835, 0.745])
image = np.empty((res, res, 3), dtype=np.float32)
image[:] = BG
color = TEAL + np.clip(intensity, 0, 1)[:, np.newaxis] * (TEAL_BRIGHT - TEAL)
image[hit] = color * intensity[:, np.newaxis]
image = np.clip(image, 0, 1)
fig, ax = plt.subplots(1, 1, figsize=(8, 8), facecolor=BG)
ax.imshow(image)
ax.set_axis_off()
fig.patch.set_facecolor(BG)
plt.show()
Batch Ray Cast Return Values
| Rays | Target | Returns | Misses |
|---|---|---|---|
| single | primitive | float (t) or None | None |
| single | form | (element_id, t) or None | None |
| batch(N) | primitive | ndarray(N,) | NaN entries |
| batch(N) | form | (ndarray[N] ids, ndarray[N] ts) | -1 / NaN entries |
(element_id, t). The batch version returns the same fields as parallel arrays, with -1 and NaN for misses. The config parameter accepts per-ray arrays (min_ts, max_ts) for variable-depth queries — useful for progressive nearest-hit across multiple meshes.