Skip to main content

omp_tui/
scene.rs

1//! Deterministic CPU ray tracing packed into braille terminal cells.
2//!
3//! [`PathTracer`] combines analytic [`Primitive`] geometry, an owning [`Bvh`],
4//! principled [`Material`]s, analytic lights, and bounded indirect transport.
5//! It traces shadows, GGX reflections, dielectric refraction, emissive
6//! surfaces, and environment illumination without per-ray allocation.
7//! Implement [`Trace`] directly when a procedural scene needs custom shading
8//! or animation; [`rasterize`] accepts either form.
9//!
10//! # Example
11//! ```
12//! use omp_tui::scene::{self, Light, Material, Object, PathTracer, Sphere, Vec3, World, vec3};
13//!
14//! let world = World::new(vec![Object::new(
15//! 	Sphere::new(Vec3::ZERO, 1.0),
16//! 	Material::diffuse(Vec3::rgb(56, 189, 248)),
17//! )])
18//! .with_light(Light::directional(vec3(-1.0, -1.0, -1.0), Vec3::ONE, 2.0))
19//! .with_environment(Vec3::rgb(5, 7, 12));
20//! let tracer = PathTracer::new(world);
21//! let mut lit = 0;
22//! scene::rasterize(&tracer, &Default::default(), 20, 8, |_, _, _, _| lit += 1);
23//! assert!(lit > 0);
24//! ```
25#![allow(clippy::suboptimal_flops, reason = "mul_add chains obscure the ray math")]
26
27mod geometry;
28mod integrator;
29mod material;
30
31use std::{
32	ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub},
33	time::Duration,
34};
35
36pub use geometry::{Aabb, Bvh, Disk, Geometry, GeometryHit, Hit, Object, Primitive, Quad, Sphere};
37pub use integrator::{Integrator, Light, PathTracer, World};
38pub use material::Material;
39
40use crate::frame::Color;
41
42/// A 3-component `f32` vector used for points, directions, and linear colors.
43#[derive(Clone, Copy, Debug, Default, PartialEq)]
44pub struct Vec3 {
45	/// X component (red when used as a color).
46	pub x: f32,
47	/// Y component (green when used as a color).
48	pub y: f32,
49	/// Z component (blue when used as a color).
50	pub z: f32,
51}
52
53/// Shorthand [`Vec3`] constructor.
54pub const fn vec3(x: f32, y: f32, z: f32) -> Vec3 {
55	Vec3 { x, y, z }
56}
57
58const fn fifth_root(value: f32) -> f32 {
59	let mut low = 0.0_f32;
60	let mut high = 1.0_f32;
61	let mut iteration = 0;
62	while iteration < 24 {
63		let middle = low.midpoint(high);
64		let square = middle * middle;
65		if square * square * middle < value {
66			low = middle;
67		} else {
68			high = middle;
69		}
70		iteration += 1;
71	}
72	low.midpoint(high)
73}
74
75const fn srgb_to_linear(channel: u8) -> f32 {
76	let encoded = channel as f32 / 255.0;
77	if encoded <= 0.04045 {
78		encoded / 12.92
79	} else {
80		let base = (encoded + 0.055) / 1.055;
81		let square = base * base;
82		square * fifth_root(square)
83	}
84}
85
86impl Vec3 {
87	/// The vector with every component set to one.
88	pub const ONE: Self = vec3(1.0, 1.0, 1.0);
89	/// The zero vector.
90	pub const ZERO: Self = vec3(0.0, 0.0, 0.0);
91
92	/// Creates a vector with every component set to `value`.
93	pub const fn splat(value: f32) -> Self {
94		vec3(value, value, value)
95	}
96
97	/// Decodes an sRGB byte triple into linear-light components.
98	pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
99		vec3(srgb_to_linear(red), srgb_to_linear(green), srgb_to_linear(blue))
100	}
101
102	/// Dot product.
103	pub fn dot(self, other: Self) -> f32 {
104		self.x * other.x + self.y * other.y + self.z * other.z
105	}
106
107	/// Squared vector length.
108	pub fn length_squared(self) -> f32 {
109		self.dot(self)
110	}
111
112	/// Vector length.
113	pub fn length(self) -> f32 {
114		self.length_squared().sqrt()
115	}
116
117	/// Cross product.
118	pub fn cross(self, other: Self) -> Self {
119		vec3(
120			self.y * other.z - self.z * other.y,
121			self.z * other.x - self.x * other.z,
122			self.x * other.y - self.y * other.x,
123		)
124	}
125
126	/// Unit-length copy; near-zero vectors stay finite.
127	pub fn normalize(self) -> Self {
128		self * (1.0 / self.dot(self).sqrt().max(1e-8))
129	}
130
131	/// Reflection of this direction around `normal`.
132	pub fn reflect(self, normal: Self) -> Self {
133		self - normal * (2.0 * self.dot(normal))
134	}
135
136	/// Refraction through `normal` at the incident/transmitted IOR ratio.
137	///
138	/// Returns `None` when total internal reflection prevents transmission.
139	pub fn refract(self, normal: Self, eta: f32) -> Option<Self> {
140		let cos_theta = (-self).dot(normal).min(1.0);
141		let perpendicular = (self + normal * cos_theta) * eta;
142		let parallel_squared = 1.0 - perpendicular.length_squared();
143		if parallel_squared < 0.0 {
144			None
145		} else {
146			Some(perpendicular - normal * parallel_squared.sqrt())
147		}
148	}
149
150	/// Largest component.
151	pub const fn max_component(self) -> f32 {
152		self.x.max(self.y).max(self.z)
153	}
154
155	/// Whether every component is finite.
156	pub const fn is_finite(self) -> bool {
157		self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
158	}
159
160	/// Componentwise clamp to `0..=1`.
161	pub const fn clamp01(self) -> Self {
162		vec3(self.x.clamp(0.0, 1.0), self.y.clamp(0.0, 1.0), self.z.clamp(0.0, 1.0))
163	}
164
165	/// Linear interpolation toward `to` by `mix` (0 = self, 1 = to).
166	pub fn lerp(self, to: Self, mix: f32) -> Self {
167		self * (1.0 - mix) + to * mix
168	}
169}
170
171impl Add for Vec3 {
172	type Output = Self;
173
174	fn add(self, other: Self) -> Self {
175		vec3(self.x + other.x, self.y + other.y, self.z + other.z)
176	}
177}
178
179impl Sub for Vec3 {
180	type Output = Self;
181
182	fn sub(self, other: Self) -> Self {
183		vec3(self.x - other.x, self.y - other.y, self.z - other.z)
184	}
185}
186
187impl Neg for Vec3 {
188	type Output = Self;
189
190	fn neg(self) -> Self {
191		vec3(-self.x, -self.y, -self.z)
192	}
193}
194
195impl AddAssign for Vec3 {
196	fn add_assign(&mut self, other: Self) {
197		*self = *self + other;
198	}
199}
200
201impl Mul<f32> for Vec3 {
202	type Output = Self;
203
204	fn mul(self, factor: f32) -> Self {
205		vec3(self.x * factor, self.y * factor, self.z * factor)
206	}
207}
208
209impl Mul for Vec3 {
210	type Output = Self;
211
212	fn mul(self, other: Self) -> Self {
213		vec3(self.x * other.x, self.y * other.y, self.z * other.z)
214	}
215}
216
217impl Mul<Vec3> for f32 {
218	type Output = Vec3;
219
220	fn mul(self, vector: Vec3) -> Vec3 {
221		vector * self
222	}
223}
224
225impl MulAssign<f32> for Vec3 {
226	fn mul_assign(&mut self, factor: f32) {
227		*self = *self * factor;
228	}
229}
230
231impl Div<f32> for Vec3 {
232	type Output = Self;
233
234	fn div(self, divisor: f32) -> Self {
235		self * (1.0 / divisor)
236	}
237}
238
239/// Encodes linear-light components through the sRGB transfer function.
240impl From<Vec3> for Color {
241	fn from(color: Vec3) -> Self {
242		let channel = |linear: f32| {
243			let linear = linear.clamp(0.0, 1.0);
244			let encoded = if linear <= 0.003_130_8 {
245				linear * 12.92
246			} else {
247				1.055 * linear.powf(1.0 / 2.4) - 0.055
248			};
249			(encoded * 255.0).round() as u8
250		};
251		Self::Rgb(channel(color.x), channel(color.y), channel(color.z))
252	}
253}
254
255/// One camera ray: `origin + dir * t`, with `dir` unit length.
256#[derive(Clone, Copy, Debug, PartialEq)]
257pub struct Ray {
258	/// Camera position.
259	pub origin: Vec3,
260	/// Unit direction of travel.
261	pub dir:    Vec3,
262}
263
264impl Ray {
265	/// Creates a ray and normalizes its travel direction.
266	pub fn new(origin: Vec3, direction: Vec3) -> Self {
267		Self { origin, dir: direction.normalize() }
268	}
269
270	/// Point reached after travelling `distance` along the ray.
271	pub fn at(self, distance: f32) -> Vec3 {
272		self.origin + self.dir * distance
273	}
274}
275
276/// An orbit camera: a position on a sphere around `target`, looking at it.
277///
278/// The default is a gentle three-quarter view sized for small scenes near
279/// the origin — closure scenes get it for free through [`Trace::advance`].
280#[derive(Clone, Copy, Debug, PartialEq)]
281pub struct Camera {
282	/// Point the camera looks at.
283	pub target:   Vec3,
284	/// Rotation around the vertical axis, in radians.
285	pub yaw:      f32,
286	/// Elevation above the horizon, in radians.
287	pub pitch:    f32,
288	/// Distance from `target`.
289	pub distance: f32,
290	/// Extra vertical offset of the camera position; the camera keeps
291	/// aiming at `target`, so lifting tilts the view.
292	pub lift:     f32,
293	/// Ray focal length: higher values narrow the field of view.
294	pub focal:    f32,
295}
296
297impl Default for Camera {
298	fn default() -> Self {
299		Self {
300			target:   Vec3::ZERO,
301			yaw:      0.0,
302			pitch:    0.44,
303			distance: 4.2,
304			lift:     0.0,
305			focal:    2.7,
306		}
307	}
308}
309
310impl Camera {
311	fn axes(&self) -> Axes {
312		let origin = self.target
313			+ vec3(
314				self.distance * self.pitch.cos() * self.yaw.sin(),
315				self.distance * self.pitch.sin() + self.lift,
316				self.distance * self.pitch.cos() * self.yaw.cos(),
317			);
318		let forward = (self.target - origin).normalize();
319		let right = forward.cross(vec3(0.0, 1.0, 0.0)).normalize();
320		let up = right.cross(forward).normalize();
321		Axes { origin, forward: forward * self.focal, right, up }
322	}
323}
324
325/// Per-frame ray basis: the camera origin and a pre-scaled view frame.
326struct Axes {
327	origin:  Vec3,
328	forward: Vec3,
329	right:   Vec3,
330	up:      Vec3,
331}
332
333impl Axes {
334	fn ray(&self, x: f32, y: f32) -> Ray {
335		Ray { origin: self.origin, dir: (self.forward + self.right * x + self.up * y).normalize() }
336	}
337}
338
339/// A raytraced scene: per-frame state plus a shader for every ray.
340///
341/// [`shade`](Self::shade) returns a unit-range color and a coverage alpha.
342/// Coverage decides which braille dots light; color, weighted by coverage,
343/// decides each cell's tint. Any `Fn(Ray) -> (Vec3, f32)` closure is a still
344/// scene viewed through [`Camera::default`].
345pub trait Trace {
346	/// Advances animation state to `now` and returns this frame's camera.
347	fn advance(&mut self, now: Duration) -> Camera {
348		let _ = now;
349		Camera::default()
350	}
351
352	/// Shades one ray: `(color, coverage)`, both in unit range.
353	fn shade(&self, ray: Ray) -> (Vec3, f32);
354}
355
356impl<F: Fn(Ray) -> (Vec3, f32)> Trace for F {
357	fn shade(&self, ray: Ray) -> (Vec3, f32) {
358		self(ray)
359	}
360}
361
362/// Rays per braille-dot side; every dot averages the square of this.
363const SUPERSAMPLE: usize = 2;
364/// Coverage at which a braille dot lights.
365const DOT_THRESHOLD: f32 = 0.24;
366/// World half-height of the view plane at the focal distance.
367const HALF_HEIGHT: f32 = 0.98;
368
369/// Braille dot layout: (x, y) inside the 2x4 cell and the bit it sets.
370const BRAILLE_DOTS: [(usize, usize, u32); 8] = [
371	(0, 0, 0x01),
372	(0, 1, 0x02),
373	(0, 2, 0x04),
374	(1, 0, 0x08),
375	(1, 1, 0x10),
376	(1, 2, 0x20),
377	(0, 3, 0x40),
378	(1, 3, 0x80),
379];
380
381/// Traces `scene` through `camera` into a `cols` × `rows` grid of braille
382/// cells with 2× supersampling.
383///
384/// `put` runs once per lit cell as `(column, row, glyph, color)`. Cells with
385/// no dot over the coverage threshold are skipped, so whatever sits behind
386/// them shows through.
387pub fn rasterize<T: Trace + ?Sized>(
388	scene: &T,
389	camera: &Camera,
390	cols: u16,
391	rows: u16,
392	mut put: impl FnMut(u16, u16, char, Color),
393) {
394	if cols == 0 || rows == 0 {
395		return;
396	}
397	let axes = camera.axes();
398	let pixel_w = cols as usize * 2 * SUPERSAMPLE;
399	let pixel_h = rows as usize * 4 * SUPERSAMPLE;
400	// Braille dots are square on a typical 1:2 terminal cell, so the view
401	// aspect is simply the raster aspect.
402	let half_w = HALF_HEIGHT * pixel_w as f32 / pixel_h as f32;
403	let step_x = 2.0 * half_w / pixel_w as f32;
404	let step_y = 2.0 * HALF_HEIGHT / pixel_h as f32;
405	for row in 0..rows as usize {
406		for col in 0..cols as usize {
407			let mut mask = 0_u32;
408			let mut cell_color = Vec3::ZERO;
409			let mut cell_weight = 0.0_f32;
410			for &(dot_x, dot_y, bit) in &BRAILLE_DOTS {
411				let mut color_sum = Vec3::ZERO;
412				let mut coverage_sum = 0.0_f32;
413				for sub_y in 0..SUPERSAMPLE {
414					for sub_x in 0..SUPERSAMPLE {
415						let px = ((col * 2 + dot_x) * SUPERSAMPLE + sub_x) as f32;
416						let py = ((row * 4 + dot_y) * SUPERSAMPLE + sub_y) as f32;
417						let (color, alpha) = scene.shade(
418							axes.ray((px + 0.5) * step_x - half_w, HALF_HEIGHT - (py + 0.5) * step_y),
419						);
420						color_sum += color * alpha;
421						coverage_sum += alpha;
422					}
423				}
424				let coverage = coverage_sum / (SUPERSAMPLE * SUPERSAMPLE) as f32;
425				if coverage >= DOT_THRESHOLD {
426					mask |= bit;
427					cell_color += color_sum * (coverage / coverage_sum.max(1e-6));
428					cell_weight += coverage;
429				}
430			}
431			if mask == 0 {
432				continue;
433			}
434			let Some(glyph) = char::from_u32(0x2800 + mask) else {
435				continue;
436			};
437			put(col as u16, row as u16, glyph, Color::from(cell_color * (1.0 / cell_weight)));
438		}
439	}
440}
441
442#[cfg(test)]
443mod tests {
444	use super::*;
445
446	/// A hard-edged white unit sphere at the origin.
447	fn sphere(ray: Ray) -> (Vec3, f32) {
448		let along = -ray.origin.dot(ray.dir);
449		let nearest = ray.origin + ray.dir * along;
450		if along > 0.0 && nearest.dot(nearest) <= 1.0 {
451			(vec3(1.0, 1.0, 1.0), 1.0)
452		} else {
453			(Vec3::ZERO, 0.0)
454		}
455	}
456
457	fn cells(cols: u16, rows: u16, scene: impl Trace) -> Vec<(u16, u16, char, Color)> {
458		let mut out = Vec::new();
459		rasterize(&scene, &Camera::default(), cols, rows, |x, y, glyph, color| {
460			out.push((x, y, glyph, color));
461		});
462		out
463	}
464
465	#[test]
466	fn srgb_inputs_round_trip_through_linear_light() {
467		for channel in 0..=u8::MAX {
468			assert_eq!(
469				Color::from(Vec3::rgb(channel, channel, channel)),
470				Color::Rgb(channel, channel, channel)
471			);
472		}
473	}
474
475	#[test]
476	fn linear_midpoint_uses_the_srgb_transfer_curve() {
477		assert_eq!(Color::from(Vec3::splat(0.5)), Color::Rgb(188, 188, 188));
478	}
479
480	#[test]
481	fn sphere_lights_the_center_and_spares_the_corners() {
482		let cells = cells(21, 9, sphere);
483		assert!(
484			cells
485				.iter()
486				.any(|&(x, y, glyph, color)| x == 10
487					&& y == 4 && glyph == '\u{28ff}'
488					&& color == Color::Rgb(255, 255, 255)),
489			"the center cell is fully covered in white"
490		);
491		let corner = |x: u16, y: u16| cells.iter().any(|&(cx, cy, ..)| cx == x && cy == y);
492		assert!(!corner(0, 0) && !corner(20, 0) && !corner(0, 8) && !corner(20, 8));
493	}
494
495	#[test]
496	fn coverage_below_the_dot_threshold_stays_dark() {
497		let haze = |_: Ray| (vec3(1.0, 1.0, 1.0), 0.2);
498		assert_eq!(cells(8, 4, haze).len(), 0, "0.2 coverage sits under the 0.24 dot threshold");
499	}
500
501	#[test]
502	fn full_coverage_lights_every_dot_of_every_cell() {
503		let wall = |_: Ray| (vec3(1.0, 0.0, 0.0), 1.0);
504		let cells = cells(8, 4, wall);
505		assert_eq!(cells.len(), 8 * 4);
506		assert!(
507			cells
508				.iter()
509				.all(|&(.., glyph, color)| glyph == '\u{28ff}' && color == Color::Rgb(255, 0, 0))
510		);
511	}
512
513	#[test]
514	fn yaw_orbits_around_an_off_axis_scene() {
515		// A sphere pushed off-center along +X lands on opposite sides of the
516		// view when the camera makes a half-turn.
517		let offset =
518			|ray: Ray| sphere(Ray { origin: ray.origin - vec3(1.4, 0.0, 0.0), dir: ray.dir });
519		let spots = |camera: &Camera| {
520			let mut spots = Vec::new();
521			rasterize(&offset, camera, 21, 9, |x, y, _, _| spots.push((x, y)));
522			spots
523		};
524		let front = spots(&Camera::default());
525		let back = spots(&Camera { yaw: std::f32::consts::PI, ..Camera::default() });
526		assert_ne!(front.len(), 0);
527		assert_ne!(back.len(), 0);
528		assert_ne!(front, back, "orbiting the camera reframes the scene");
529	}
530}