1#![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#[derive(Clone, Copy, Debug, Default, PartialEq)]
44pub struct Vec3 {
45 pub x: f32,
47 pub y: f32,
49 pub z: f32,
51}
52
53pub 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 pub const ONE: Self = vec3(1.0, 1.0, 1.0);
89 pub const ZERO: Self = vec3(0.0, 0.0, 0.0);
91
92 pub const fn splat(value: f32) -> Self {
94 vec3(value, value, value)
95 }
96
97 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 pub fn dot(self, other: Self) -> f32 {
104 self.x * other.x + self.y * other.y + self.z * other.z
105 }
106
107 pub fn length_squared(self) -> f32 {
109 self.dot(self)
110 }
111
112 pub fn length(self) -> f32 {
114 self.length_squared().sqrt()
115 }
116
117 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 pub fn normalize(self) -> Self {
128 self * (1.0 / self.dot(self).sqrt().max(1e-8))
129 }
130
131 pub fn reflect(self, normal: Self) -> Self {
133 self - normal * (2.0 * self.dot(normal))
134 }
135
136 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 pub const fn max_component(self) -> f32 {
152 self.x.max(self.y).max(self.z)
153 }
154
155 pub const fn is_finite(self) -> bool {
157 self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
158 }
159
160 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 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
239impl 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#[derive(Clone, Copy, Debug, PartialEq)]
257pub struct Ray {
258 pub origin: Vec3,
260 pub dir: Vec3,
262}
263
264impl Ray {
265 pub fn new(origin: Vec3, direction: Vec3) -> Self {
267 Self { origin, dir: direction.normalize() }
268 }
269
270 pub fn at(self, distance: f32) -> Vec3 {
272 self.origin + self.dir * distance
273 }
274}
275
276#[derive(Clone, Copy, Debug, PartialEq)]
281pub struct Camera {
282 pub target: Vec3,
284 pub yaw: f32,
286 pub pitch: f32,
288 pub distance: f32,
290 pub lift: f32,
293 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
325struct 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
339pub trait Trace {
346 fn advance(&mut self, now: Duration) -> Camera {
348 let _ = now;
349 Camera::default()
350 }
351
352 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
362const SUPERSAMPLE: usize = 2;
364const DOT_THRESHOLD: f32 = 0.24;
366const HALF_HEIGHT: f32 = 0.98;
368
369const 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
381pub 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 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 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 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}