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
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.
Mais primitivas – Adicione suporte a cubos (interseção AABB), triângulos (interseção Moller-Trumbore) e cilindros, permitindo cenas muito mais complexas.
Texturas – Implemente mapeamento UV para esferas e aplique texturas carregadas de arquivos de imagem, além de texturas procedurais como noise de Perlin.
Saída PNG direta – Use a crate
imagepara salvar diretamente em PNG sem precisar de ferramentas externas, e adicione opção de resolução pela linha de comando.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
- Vec e Coleções – Coleções usadas para armazenar pixels e objetos
- Iteradores – Iteração funcional usada extensivamente na renderização
- Módulo IO – Escrita do arquivo PPM com BufWriter
- Executando Threads – Paralelismo com rayon para renderização
- Otimização de Performance – Técnicas para acelerar cálculos intensivos