atanor 0.1.0

Motor 3D ray-traced que vive solo y exclusivamente en la terminal.
Documentation
use crate::math::{halton, reflect, saturate, Ray};
use crate::render::camera::Camera;
use crate::render::framebuffer::Framebuffer;
use crate::render::scene::Scene;
use glam::{Vec2, Vec3};
use rayon::prelude::*;

const MAX_BOUNCES: u32 = 2;
const T_MIN: f32 = 1e-3;
const T_MAX: f32 = 1e3;

pub const MIN_SPP: u32 = 1;
pub const MAX_SPP: u32 = 16;

/// `samples_per_pixel` controla SSAA. 1 = un rayo en el centro (rápido, alias);
/// 4 = 4 muestras Halton(2,3) promediadas (bordes suaves, 4× coste).
pub fn render(scene: &Scene, camera: &Camera, fb: &mut Framebuffer, samples_per_pixel: u32) {
    let w = fb.width;
    let h = fb.height;
    let spp = samples_per_pixel.clamp(MIN_SPP, MAX_SPP);

    // Pre-calculamos los offsets sub-píxel una vez por frame (deterministas, no
    // dependen del píxel). Halton da baja-discrepancia → cobertura uniforme.
    let offsets: Vec<Vec2> = if spp == 1 {
        vec![Vec2::splat(0.5)]
    } else {
        (1..=spp).map(|i| Vec2::new(halton(i, 2), halton(i, 3))).collect()
    };
    let inv_spp = 1.0 / spp as f32;

    // Traceamos en filas paralelas; cada fila escribe en su slice exclusivo del buffer.
    fb.pixels
        .par_chunks_mut(w as usize)
        .enumerate()
        .for_each(|(y, row)| {
            for x in 0..w {
                let mut acc = Vec3::ZERO;
                for &j in &offsets {
                    let ray = camera.ray(x, y as u16, w, h, j);
                    acc += trace(scene, &ray, MAX_BOUNCES);
                }
                row[x as usize] = acc * inv_spp;
            }
        });
}

fn trace(scene: &Scene, ray: &Ray, depth: u32) -> Vec3 {
    let Some(hit) = scene.hit(ray, T_MIN, T_MAX) else {
        return scene.sky(ray.dir);
    };
    let mat = &scene.materials[hit.material];
    let albedo = mat.sample_albedo(hit.point);

    let mut color = scene.ambient * albedo;

    for light in &scene.lights {
        let to_light_vec = light.position - hit.point;
        let dist = to_light_vec.length();
        let l = to_light_vec / dist;

        // Sombra dura: rayo hacia la luz, si toca algo antes de llegar -> sombra.
        let shadow_ray = Ray { origin: hit.point + hit.normal * 1e-3, dir: l };
        let in_shadow = scene
            .hit(&shadow_ray, T_MIN, dist - 1e-3)
            .is_some();
        if in_shadow {
            continue;
        }

        // Atenuación suave por distancia (no físicamente exacta, pero estable visualmente).
        let atten = light.intensity / (1.0 + 0.05 * dist * dist);

        let ndotl = saturate(hit.normal.dot(l));
        let diffuse = albedo * ndotl;

        // Blinn-Phong specular
        let view = -ray.dir;
        let half = (l + view).normalize();
        let ndoth = saturate(hit.normal.dot(half));
        let spec = ndoth.powf(mat.shininess) * mat.specular;

        color += (diffuse + Vec3::splat(spec)) * light.color * atten;
    }

    // Reflexiones (segundo rebote como mucho — barato y suficiente para terminal).
    if depth > 0 && mat.reflectivity > 0.0 {
        let refl_dir = reflect(ray.dir, hit.normal).normalize();
        let refl_ray = Ray {
            origin: hit.point + hit.normal * 1e-3,
            dir: refl_dir,
        };
        let refl_color = trace(scene, &refl_ray, depth - 1);
        color = color.lerp(refl_color, mat.reflectivity);
    }

    // Gamma 2.2 ligera para que no quede aplastado en el terminal.
    Vec3::new(color.x.powf(1.0 / 2.2), color.y.powf(1.0 / 2.2), color.z.powf(1.0 / 2.2))
}