Ray Tracer Basico em Rust

Construa um ray tracer do zero em Rust: matemática vetorial, interseção raio-esfera, iluminação Phong, sombras, reflexão e saída PPM.

Neste projeto vamos construir um ray tracer (traçador de raios) do zero em Rust. Ray tracing é uma técnica de renderização que simula o caminho da luz para gerar imagens fotorrealistas. Nosso ray tracer vai renderizar esferas coloridas com iluminação Phong (ambient + diffuse + specular), sombras, reflexões e saída em formato PPM. Além disso, usaremos a crate rayon para paralelizar o processamento e aproveitar todos os núcleos do processador.

Este é um projeto fascinante que combina matemática (vetores, geometria analítica) com programação de sistemas. Ao final, você terá gerado uma imagem 3D realista inteiramente a partir de cálculos matemáticos, sem nenhuma biblioteca gráfica.

O Que Vamos Construir

Um ray tracer com as seguintes funcionalidades:

  • Estruturas de vetores 3D com operações matemáticas completas
  • Cálculo de interseção raio-esfera usando geometria analítica
  • Modelo de iluminação Phong (componentes ambient, diffuse e specular)
  • Sombras projetadas entre objetos
  • Reflexões recursivas com profundidade configurável
  • Cena com múltiplas esferas, plano e fonte de luz
  • Saída em formato PPM (imagem sem compressão)
  • Renderização paralela com rayon

Estrutura do Projeto

raytracer/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new raytracer
cd raytracer

Edite o Cargo.toml:

[package]
name = "raytracer"
version = "0.1.0"
edition = "2021"

[dependencies]
rayon = "1.10"

Usamos rayon para paralelizar o cálculo de cada linha da imagem. O restante é Rust puro sem dependências.

Passo 1: Matemática Vetorial

A base de qualquer ray tracer é uma boa implementação de vetores 3D. Implementamos todas as operações com operator overloading.

use rayon::prelude::*;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::ops::{Add, Mul, Neg, Sub};

/// Vetor 3D (usado para posições, direções e cores)
#[derive(Clone, Copy, Debug)]
struct Vec3 {
    x: f64,
    y: f64,
    z: f64,
}

impl Vec3 {
    fn novo(x: f64, y: f64, z: f64) -> Self {
        Vec3 { x, y, z }
    }

    fn zero() -> Self {
        Vec3 { x: 0.0, y: 0.0, z: 0.0 }
    }

    /// Produto escalar (dot product)
    fn dot(&self, outro: &Vec3) -> f64 {
        self.x * outro.x + self.y * outro.y + self.z * outro.z
    }

    /// Comprimento do vetor
    fn comprimento(&self) -> f64 {
        self.dot(self).sqrt()
    }

    /// Retorna o vetor normalizado (comprimento 1)
    fn normalizar(&self) -> Vec3 {
        let comp = self.comprimento();
        Vec3 {
            x: self.x / comp,
            y: self.y / comp,
            z: self.z / comp,
        }
    }

    /// Reflexão de um vetor em relação a uma normal
    fn refletir(&self, normal: &Vec3) -> Vec3 {
        *self - *normal * 2.0 * self.dot(normal)
    }

    /// Multiplica componente a componente (para cores)
    fn componente_mul(&self, outro: &Vec3) -> Vec3 {
        Vec3 {
            x: self.x * outro.x,
            y: self.y * outro.y,
            z: self.z * outro.z,
        }
    }

    /// Limita cada componente entre 0 e 1 (para cores)
    fn clamp(&self) -> Vec3 {
        Vec3 {
            x: self.x.clamp(0.0, 1.0),
            y: self.y.clamp(0.0, 1.0),
            z: self.z.clamp(0.0, 1.0),
        }
    }
}

impl Add for Vec3 {
    type Output = Vec3;
    fn add(self, rhs: Vec3) -> Vec3 {
        Vec3::novo(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
    }
}

impl Sub for Vec3 {
    type Output = Vec3;
    fn sub(self, rhs: Vec3) -> Vec3 {
        Vec3::novo(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
    }
}

impl Mul<f64> for Vec3 {
    type Output = Vec3;
    fn mul(self, escalar: f64) -> Vec3 {
        Vec3::novo(self.x * escalar, self.y * escalar, self.z * escalar)
    }
}

impl Neg for Vec3 {
    type Output = Vec3;
    fn neg(self) -> Vec3 {
        Vec3::novo(-self.x, -self.y, -self.z)
    }
}

O Vec3 serve tanto para posições e direções quanto para cores (r, g, b como x, y, z). Os traits Add, Sub, Mul e Neg permitem escrever expressões matemáticas naturais como posicao + direcao * t.

Passo 2: Raios, Esferas e Interseção

Agora definimos os raios, os objetos da cena e o cálculo de interseção.

/// Um raio com origem e direção
struct Raio {
    origem: Vec3,
    direcao: Vec3,
}

impl Raio {
    fn novo(origem: Vec3, direcao: Vec3) -> Self {
        Raio { origem, direcao: direcao.normalizar() }
    }

    /// Ponto ao longo do raio na distância t
    fn ponto_em(&self, t: f64) -> Vec3 {
        self.origem + self.direcao * t
    }
}

/// Material de uma superfície
#[derive(Clone, Copy)]
struct Material {
    cor: Vec3,
    ambiente: f64,
    difuso: f64,
    especular: f64,
    brilho: f64,
    reflexao: f64,
}

/// Uma esfera na cena
struct Esfera {
    centro: Vec3,
    raio: f64,
    material: Material,
}

/// Informações sobre uma interseção raio-objeto
struct Intersecao {
    distancia: f64,
    ponto: Vec3,
    normal: Vec3,
    material: Material,
}

impl Esfera {
    /// Calcula a interseção do raio com a esfera
    /// Usa a equação quadrática: |O + tD - C|^2 = r^2
    fn intersecar(&self, raio: &Raio) -> Option<Intersecao> {
        let oc = raio.origem - self.centro;

        let a = raio.direcao.dot(&raio.direcao);
        let b = 2.0 * oc.dot(&raio.direcao);
        let c = oc.dot(&oc) - self.raio * self.raio;

        let discriminante = b * b - 4.0 * a * c;

        if discriminante < 0.0 {
            return None; // Raio não atinge a esfera
        }

        let raiz = discriminante.sqrt();
        let t1 = (-b - raiz) / (2.0 * a);
        let t2 = (-b + raiz) / (2.0 * a);

        // Pega a interseção mais próxima que está à frente da câmera
        let t = if t1 > 0.001 { t1 } else if t2 > 0.001 { t2 } else { return None };

        let ponto = raio.ponto_em(t);
        let normal = (ponto - self.centro).normalizar();

        Some(Intersecao {
            distancia: t,
            ponto,
            normal,
            material: self.material,
        })
    }
}

/// Plano infinito (usado como chão)
struct Plano {
    ponto: Vec3,
    normal: Vec3,
    material: Material,
}

impl Plano {
    fn intersecar(&self, raio: &Raio) -> Option<Intersecao> {
        let denom = self.normal.dot(&raio.direcao);

        if denom.abs() < 1e-6 {
            return None; // Raio paralelo ao plano
        }

        let t = (self.ponto - raio.origem).dot(&self.normal) / denom;

        if t < 0.001 {
            return None;
        }

        let ponto = raio.ponto_em(t);

        // Padrão xadrez para o plano
        let mut material = self.material;
        let quadrado = ((ponto.x.floor() + ponto.z.floor()) as i64).abs() % 2;
        if quadrado == 0 {
            material.cor = material.cor * 0.5;
        }

        Some(Intersecao {
            distancia: t,
            ponto,
            normal: self.normal,
            material,
        })
    }
}

A interseção raio-esfera é resolvida algebricamente: substituímos a equação paramétrica do raio na equação da esfera e obtemos uma equação quadrática. O discriminante indica se há 0, 1 ou 2 interseções. O t > 0.001 evita “shadow acne” (auto-interseção por erro de ponto flutuante).

Passo 3: Iluminação Phong e Sombras

Agora implementamos o modelo de iluminação e a detecção de sombras.

/// Fonte de luz pontual
struct Luz {
    posicao: Vec3,
    intensidade: f64,
}

/// A cena completa com objetos e luzes
struct Cena {
    esferas: Vec<Esfera>,
    plano: Plano,
    luzes: Vec<Luz>,
    cor_fundo: Vec3,
}

impl Cena {
    /// Encontra a interseção mais próxima com qualquer objeto da cena
    fn intersecar(&self, raio: &Raio) -> Option<Intersecao> {
        let mut mais_proximo: Option<Intersecao> = None;

        // Testa esferas
        for esfera in &self.esferas {
            if let Some(inter) = esfera.intersecar(raio) {
                let eh_mais_proximo = mais_proximo
                    .as_ref()
                    .map_or(true, |atual| inter.distancia < atual.distancia);
                if eh_mais_proximo {
                    mais_proximo = Some(inter);
                }
            }
        }

        // Testa plano
        if let Some(inter) = self.plano.intersecar(raio) {
            let eh_mais_proximo = mais_proximo
                .as_ref()
                .map_or(true, |atual| inter.distancia < atual.distancia);
            if eh_mais_proximo {
                mais_proximo = Some(inter);
            }
        }

        mais_proximo
    }

    /// Verifica se um ponto está na sombra de uma luz
    fn em_sombra(&self, ponto: &Vec3, direcao_luz: &Vec3) -> bool {
        let raio_sombra = Raio::novo(*ponto, *direcao_luz);
        self.intersecar(&raio_sombra).is_some()
    }

    /// Calcula a cor de um raio usando iluminação Phong com reflexão
    fn calcular_cor(&self, raio: &Raio, profundidade: u32) -> Vec3 {
        if profundidade == 0 {
            return self.cor_fundo;
        }

        let intersecao = match self.intersecar(raio) {
            Some(i) => i,
            None => return self.cor_fundo,
        };

        let material = &intersecao.material;
        let mut cor_final = material.cor * material.ambiente; // Componente ambiente

        for luz in &self.luzes {
            let direcao_luz = (luz.posicao - intersecao.ponto).normalizar();

            // Verifica sombra
            if self.em_sombra(&intersecao.ponto, &direcao_luz) {
                continue;
            }

            // Componente difusa (Lambert)
            let nl = intersecao.normal.dot(&direcao_luz).max(0.0);
            cor_final = cor_final + material.cor * material.difuso * nl * luz.intensidade;

            // Componente especular (Blinn-Phong)
            let meia_direcao = (direcao_luz - raio.direcao).normalizar();
            let nh = intersecao.normal.dot(&meia_direcao).max(0.0);
            let especular = nh.powf(material.brilho) * material.especular * luz.intensidade;
            cor_final = cor_final + Vec3::novo(1.0, 1.0, 1.0) * especular;
        }

        // Reflexão recursiva
        if material.reflexao > 0.0 {
            let direcao_reflexao = raio.direcao.refletir(&intersecao.normal);
            let raio_reflexao = Raio::novo(intersecao.ponto, direcao_reflexao);
            let cor_reflexao = self.calcular_cor(&raio_reflexao, profundidade - 1);
            cor_final = cor_final * (1.0 - material.reflexao)
                + cor_reflexao * material.reflexao;
        }

        cor_final.clamp()
    }
}

A iluminação Phong combina três componentes: ambiente (luz base), difusa (iluminação direcional) e especular (brilho reflexivo). Antes de calcular a contribuição de cada luz, verificamos se o ponto está na sombra. Reflexões são calculadas recursivamente com profundidade limitada para evitar loops infinitos.

Passo 4: Renderização e main.rs Completo

Agora montamos a cena, a câmera e o loop de renderização paralela.

/// Salva a imagem no formato PPM (Portable Pixel Map)
fn salvar_ppm(caminho: &str, pixels: &[Vec3], largura: usize, altura: usize) {
    let arquivo = File::create(caminho).expect("Não foi possível criar o arquivo");
    let mut escritor = BufWriter::new(arquivo);

    writeln!(escritor, "P3").unwrap();
    writeln!(escritor, "{} {}", largura, altura).unwrap();
    writeln!(escritor, "255").unwrap();

    for pixel in pixels {
        let r = (pixel.x * 255.0) as u8;
        let g = (pixel.y * 255.0) as u8;
        let b = (pixel.z * 255.0) as u8;
        writeln!(escritor, "{} {} {}", r, g, b).unwrap();
    }
}

fn main() {
    let largura: usize = 800;
    let altura: usize = 600;
    let profundidade_maxima: u32 = 5;

    // Define os materiais
    let vermelho = Material {
        cor: Vec3::novo(0.9, 0.1, 0.1), ambiente: 0.1, difuso: 0.7,
        especular: 0.5, brilho: 50.0, reflexao: 0.2,
    };
    let azul = Material {
        cor: Vec3::novo(0.1, 0.3, 0.9), ambiente: 0.1, difuso: 0.7,
        especular: 0.6, brilho: 80.0, reflexao: 0.3,
    };
    let verde = Material {
        cor: Vec3::novo(0.1, 0.8, 0.2), ambiente: 0.1, difuso: 0.7,
        especular: 0.3, brilho: 30.0, reflexao: 0.1,
    };
    let espelho = Material {
        cor: Vec3::novo(0.8, 0.8, 0.8), ambiente: 0.05, difuso: 0.2,
        especular: 0.9, brilho: 200.0, reflexao: 0.8,
    };
    let chao = Material {
        cor: Vec3::novo(0.6, 0.6, 0.6), ambiente: 0.1, difuso: 0.6,
        especular: 0.1, brilho: 10.0, reflexao: 0.1,
    };

    // Monta a cena
    let cena = Cena {
        esferas: vec![
            Esfera { centro: Vec3::novo(0.0, 1.0, -5.0), raio: 1.0, material: vermelho },
            Esfera { centro: Vec3::novo(-2.5, 0.7, -4.0), raio: 0.7, material: azul },
            Esfera { centro: Vec3::novo(2.0, 0.5, -3.5), raio: 0.5, material: verde },
            Esfera { centro: Vec3::novo(1.0, 1.5, -7.0), raio: 1.5, material: espelho },
        ],
        plano: Plano {
            ponto: Vec3::novo(0.0, 0.0, 0.0),
            normal: Vec3::novo(0.0, 1.0, 0.0),
            material: chao,
        },
        luzes: vec![
            Luz { posicao: Vec3::novo(-5.0, 8.0, -3.0), intensidade: 1.0 },
            Luz { posicao: Vec3::novo(5.0, 5.0, -1.0), intensidade: 0.6 },
        ],
        cor_fundo: Vec3::novo(0.4, 0.6, 0.9), // Azul céu
    };

    // Configuração da câmera
    let origem_camera = Vec3::novo(0.0, 2.0, 2.0);
    let fov = 60.0_f64.to_radians();
    let aspecto = largura as f64 / altura as f64;
    let escala = (fov / 2.0).tan();

    println!("Renderizando imagem {}x{} com {} esferas...", largura, altura, cena.esferas.len());
    let inicio = std::time::Instant::now();

    // Renderiza em paralelo usando rayon (cada linha é processada independentemente)
    let pixels: Vec<Vec3> = (0..altura)
        .into_par_iter()
        .flat_map(|y| {
            (0..largura)
                .map(|x| {
                    // Converte coordenadas do pixel para direção do raio
                    let px = (2.0 * (x as f64 + 0.5) / largura as f64 - 1.0) * escala * aspecto;
                    let py = (1.0 - 2.0 * (y as f64 + 0.5) / altura as f64) * escala;

                    let direcao = Vec3::novo(px, py, -1.0).normalizar();
                    let raio = Raio::novo(origem_camera, direcao);

                    cena.calcular_cor(&raio, profundidade_maxima)
                })
                .collect::<Vec<_>>()
        })
        .collect();

    let duracao = inicio.elapsed();
    println!("Renderização concluída em {:.2}s", duracao.as_secs_f64());

    // Salva a imagem
    let caminho = "cena.ppm";
    salvar_ppm(caminho, &pixels, largura, altura);
    println!("Imagem salva em '{}'", caminho);
    println!("Para visualizar: convert cena.ppm cena.png (requer ImageMagick)");
}

O rayon paraleliza automaticamente o processamento das linhas com into_par_iter(), distribuindo o trabalho entre todos os núcleos. Para cada pixel, calculamos a direção do raio usando a projeção perspectiva baseada no campo de visão (FOV) e disparamos o ray trace recursivo.

Como Executar

Compile e execute o projeto:

cargo run --release

O modo --release é fortemente recomendado pois as otimizações fazem enorme diferença em cálculos de ponto flutuante. Saída esperada:

Renderizando imagem 800x600 com 4 esferas...
Renderização concluída em 0.85s
Imagem salva em 'cena.ppm'
Para visualizar: convert cena.ppm cena.png (requer ImageMagick)

Para converter o PPM em PNG (requer ImageMagick):

convert cena.ppm cena.png

Ou abra o arquivo .ppm diretamente em editores como GIMP ou visualizadores como feh.

Desafios para Expandir

  1. Anti-aliasing – Dispare múltiplos raios por pixel com pequenas variações aleatórias (supersampling) e calcule a média das cores para suavizar as bordas serrilhadas.

  2. Mais primitivas – Adicione suporte a cubos (interseção AABB), triângulos (interseção Moller-Trumbore) e cilindros, permitindo cenas muito mais complexas.

  3. Texturas – Implemente mapeamento UV para esferas e aplique texturas carregadas de arquivos de imagem, além de texturas procedurais como noise de Perlin.

  4. Saída PNG direta – Use a crate image para salvar diretamente em PNG sem precisar de ferramentas externas, e adicione opção de resolução pela linha de comando.

  5. Refração e transparência – Implemente a Lei de Snell para materiais transparentes como vidro e água, com reflexão parcial usando as equações de Fresnel.

Veja Também