A pinhole camera's ray¶
The direction a pinhole camera sees through a point of its picture.
The camera at its eye looks at its target, the world's up not parallel to the line of sight, its vertical field of view fov; the point is u across the picture from its left edge and v down from its top, each a fraction of the picture, whose aspect is its width over its height: forward f = normalize(target - eye), right r = normalize(f x up), up' = r x f, sx = (u 2 - 1) tan(fov / 2) aspect, sy = (1 - v 2) tan(fov / 2), d = normalize(f + r sx + up' sy). A unit vector, dimensionless. camera_ray_through states the law; it reads only its parameters, so it is a function, callable from anywhere (a tapped point's ray, say). camera_ray and camera_ray_at give it a pixel of a 2-D lattice, w by h, x to the right and y down (row 0 the top), counted by the cells beside it (along), so they are no functions: u = (x + 1/2) / w, v = (y + 1/2) / h, aspect = w / h. Source: Shirley and Marschner, Fundamentals of Computer Graphics, 3rd ed., section 4.3 (perspective views). Checked against a reference in numpy, which has its own tests.
- Source
- Shirley and Marschner, Fundamentals of Computer Graphics, 3rd ed., section 4.3.
- Proof
- reference, invariant, limit.
camera_ray.trait
namespace lusine.render;
vector<float, 3> camera_ray(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up, float<rad> fov)
{
return camera_ray_at(eye = eye, target = target, up = up, fov = fov, sub_x = 0.5, sub_y = 0.5);
}
// The same through a point of the pixel other than its centre (several rays a pixel): sub_x and
// sub_y in [0, 1) across the pixel from its left and top edges, 1/2 each the centre, in place of the 1/2s above.
vector<float, 3> camera_ray_at(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up, float<rad> fov,
float sub_x, float sub_y)
{
let px = count(along(-x));
let py = count(along(-y));
let w = px + count(along(+x)) + 1;
let h = py + count(along(+y)) + 1;
return camera_ray_through(eye = eye, target = target, up = up, fov = fov, aspect = float(w) / h,
u = (px + sub_x) / w, v = (py + sub_y) / h);
}
// The law itself, through the point (u, v) of a picture whose width over its height is aspect: u = v = 1/2 its middle,
// (0, 0) its top left corner and (1, 1) its bottom right.
vector<float, 3> camera_ray_through(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up,
float<rad> fov, float aspect, float u, float v)
{
let forward = normalize(target - eye);
let right = normalize(cross(forward, up));
let upward = cross(right, forward);
let tan_half = tan(fov / 2.0);
let sx = (u * 2.0 - 1.0) * tan_half * aspect;
let sy = (1.0 - v * 2.0) * tan_half;
return normalize(forward + right * sx + upward * sy);
}
The direction through a pixel's centre from a camera at its eye looking at its target, its vertical field of view fov. The law is stated once, by camera_ray_through: the ray through a point of the picture, given as fractions across and down it and the picture's width over its height. It reads nothing but its parameters, so a game calls it anywhere, for a tapped point of a viewport among others. camera_ray and camera_ray_at give it a pixel: its place and the picture's size counted from the lattice's cells beside it (along), so the program here is a picture of its own, not a lattice of cases. One of the kernels lusine.render.table draws a table's shapes with.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
F32 = np.float32
U = float(np.finfo(F32).eps) / 2 # binary32's unit roundoff, 2^-24
rng = np.random.default_rng(2026)
The kernel¶
The law as the library states it.
print(trait_run.source('render/camera_ray.trait'))
// lusine.render.camera_ray: the direction a pinhole camera sees through a point of its picture. The camera at its eye
// looks at its target, the world's up not parallel to the line of sight, its vertical field of view fov; the point is
// u across the picture from its left edge and v down from its top, each a fraction of the picture, whose aspect is its
// width over its height:
// forward f = normalize(target - eye), right r = normalize(f x up), up' = r x f,
// sx = (u 2 - 1) tan(fov / 2) aspect, sy = (1 - v 2) tan(fov / 2),
// d = normalize(f + r sx + up' sy).
// A unit vector, dimensionless. camera_ray_through states the law; it reads only its parameters, so it is a function,
// callable from anywhere (a tapped point's ray, say). camera_ray and camera_ray_at give it a pixel of a 2-D lattice,
// w by h, x to the right and y down (row 0 the top), counted by the cells beside it (along), so they are no functions:
// u = (x + 1/2) / w, v = (y + 1/2) / h, aspect = w / h.
// Source: Shirley and Marschner, Fundamentals of Computer Graphics, 3rd ed., section 4.3 (perspective views).
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
vector<float, 3> camera_ray(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up, float<rad> fov)
{
return camera_ray_at(eye = eye, target = target, up = up, fov = fov, sub_x = 0.5, sub_y = 0.5);
}
// The same through a point of the pixel other than its centre (several rays a pixel): sub_x and
// sub_y in [0, 1) across the pixel from its left and top edges, 1/2 each the centre, in place of the 1/2s above.
vector<float, 3> camera_ray_at(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up, float<rad> fov,
float sub_x, float sub_y)
{
let px = count(along(-x));
let py = count(along(-y));
let w = px + count(along(+x)) + 1;
let h = py + count(along(+y)) + 1;
return camera_ray_through(eye = eye, target = target, up = up, fov = fov, aspect = float(w) / h,
u = (px + sub_x) / w, v = (py + sub_y) / h);
}
// The law itself, through the point (u, v) of a picture whose width over its height is aspect: u = v = 1/2 its middle,
// (0, 0) its top left corner and (1, 1) its bottom right.
vector<float, 3> camera_ray_through(vector<float<m>, 3> eye, vector<float<m>, 3> target, vector<float, 3> up,
float<rad> fov, float aspect, float u, float v)
{
let forward = normalize(target - eye);
let right = normalize(cross(forward, up));
let upward = cross(right, forward);
let tan_half = tan(fov / 2.0);
let sx = (u * 2.0 - 1.0) * tan_half * aspect;
let sy = (1.0 - v * 2.0) * tan_half;
return normalize(forward + right * sx + upward * sy);
}
The program¶
A picture of pixels, each with its ray; the camera a single the host sets; main() binds the kernel over the picture. rays(w, h, eye, target, up, fov) returns them laid out [x, y].
PROGRAM = '''namespace picture;
lusine.version version = { { number = 1 } };
struct pixel { vector<float, 3> ray; };
lattice<pixel, 2> screen(spacing = 1 m, boundary = wall);
host struct { vector<float<m>, 3> eye; vector<float<m>, 3> target; vector<float, 3> up; float<rad> fov; } camera;
void main() { screen.ray = lusine.render.camera_ray(camera.eye, camera.target, camera.up, camera.fov); }
'''
print(PROGRAM)
from lusine.kernels.render.camera_ray import camera_ray as reference
def rays(w, h, eye, target, up, fov):
run = trait_run.Program(['render/camera_ray.trait'], PROGRAM, {'picture.screen': [w, h]})
for f, v in (('eye', eye), ('target', target), ('up', up), ('fov', fov)):
run.store('picture.camera', f, v)
return run.steps('picture.main', 1, [('picture.screen', 'ray')])[0][0]
namespace picture;
lusine.version version = { { number = 1 } };
struct pixel { vector<float, 3> ray; };
lattice<pixel, 2> screen(spacing = 1 m, boundary = wall);
host struct { vector<float<m>, 3> eye; vector<float<m>, 3> target; vector<float, 3> up; float<rad> fov; } camera;
void main() { screen.ray = lusine.render.camera_ray(camera.eye, camera.target, camera.up, camera.fov); }
Reference: every pixel's ray¶
A 32 by 18 picture through the table view's camera: each pixel's ray within a few binary32 roundings of the float64 reference's (its tangent the library's fixed sequence).
eye, target, up, fov = [0.2, 0.35, -0.55], [1.2, 0.0, 0.05], [0.0, 1.0, 0.0], float(np.radians(40.0))
d = rays(32, 18, eye, target, up, fov)
xs, ys = np.meshgrid(np.arange(32), np.arange(18), indexing='ij')
want = reference(eye, target, up, fov, xs, ys, 32, 18)
err = float(np.max(np.abs(d - want)))
assert err <= 32 * U, err / U
print(f'{xs.size} rays within {err / U:.1f} x 2^-24 of the float64 reference')
576 rays within 2.1 x 2^-24 of the float64 reference
Invariant: unit rays, mirrored across the middle¶
Every ray is a unit vector, and the pixels mirrored across the middle column see rays mirrored across the camera's vertical plane.
assert np.allclose(np.linalg.norm(d, axis=-1), 1.0, rtol=0, atol=4 * U)
f = np.subtract(target, eye); f /= np.linalg.norm(f)
r = np.cross(f, up); r /= np.linalg.norm(r)
assert np.allclose(d @ r, -(d[::-1] @ r), rtol=0, atol=16 * U)
print('unit rays, mirrored')
unit rays, mirrored
Limit: the middle pixel looks at the target¶
In a picture of an odd width and height the middle pixel's ray is the line of sight; a one-pixel picture's only ray too.
m = rays(9, 5, eye, target, up, fov)[4, 2]
one = rays(1, 1, eye, target, up, fov)[0, 0]
assert np.allclose(m, f, rtol=0, atol=8 * U) and np.allclose(one, f, rtol=0, atol=8 * U)
print('the middle pixel sees the target')
the middle pixel sees the target