Ray Tracer em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um ray tracer simples em Zig que renderiza uma cena 3D com esferas, iluminação difusa, sombras e reflexões. O resultado é salvo em formato PPM (imagem que pode ser visualizada em qualquer viewer).
O Que Vamos Construir
Nosso ray tracer vai:
- Renderizar esferas 3D com posição, raio e cor
- Implementar iluminação difusa (Lambert) e especular (Phong)
- Calcular sombras projetadas por objetos
- Suportar reflexões (espelhos)
- Salvar a imagem em formato PPM
- Incluir uma cena de demonstração com múltiplas esferas
Por Que Este Projeto?
Ray tracing é a técnica de renderização mais elegante: simulamos raios de luz fisicamente. É usado em filmes, jogos (RTX) e design. Implementar um nos ensina álgebra linear aplicada, geometria computacional e otimização. Zig é ideal pelo controle de performance e matemática de ponto flutuante.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Noções básicas de vetores 3D e álgebra linear
- Familiaridade com I/O de arquivos
Passo 1: Estrutura do Projeto
mkdir ray-tracer
cd ray-tracer
zig init
Passo 2: Matemática Vetorial
const std = @import("std");
const fs = std.fs;
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
const math = std.math;
/// Vetor 3D com operações matemáticas.
const Vec3 = struct {
x: f64,
y: f64,
z: f64,
pub fn init(x: f64, y: f64, z: f64) Vec3 {
return .{ .x = x, .y = y, .z = z };
}
pub fn zero() Vec3 {
return .{ .x = 0, .y = 0, .z = 0 };
}
pub fn add(a: Vec3, b: Vec3) Vec3 {
return .{ .x = a.x + b.x, .y = a.y + b.y, .z = a.z + b.z };
}
pub fn sub(a: Vec3, b: Vec3) Vec3 {
return .{ .x = a.x - b.x, .y = a.y - b.y, .z = a.z - b.z };
}
pub fn mul(a: Vec3, s: f64) Vec3 {
return .{ .x = a.x * s, .y = a.y * s, .z = a.z * s };
}
pub fn mulVec(a: Vec3, b: Vec3) Vec3 {
return .{ .x = a.x * b.x, .y = a.y * b.y, .z = a.z * b.z };
}
pub fn dot(a: Vec3, b: Vec3) f64 {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
pub fn length(v: Vec3) f64 {
return @sqrt(dot(v, v));
}
pub fn normalize(v: Vec3) Vec3 {
const len = length(v);
if (len == 0) return zero();
return mul(v, 1.0 / len);
}
pub fn reflect(v: Vec3, n: Vec3) Vec3 {
return sub(v, mul(n, 2.0 * dot(v, n)));
}
pub fn clamp01(v: Vec3) Vec3 {
return .{
.x = @min(1.0, @max(0.0, v.x)),
.y = @min(1.0, @max(0.0, v.y)),
.z = @min(1.0, @max(0.0, v.z)),
};
}
};
/// Cor RGB (valores 0.0 - 1.0).
const Cor = Vec3;
Passo 3: Geometria (Raios e Esferas)
/// Raio definido por origem e direção.
const Raio = struct {
origem: Vec3,
direcao: Vec3,
/// Ponto no raio na distância t.
pub fn ponto(self: Raio, t: f64) Vec3 {
return Vec3.add(self.origem, Vec3.mul(self.direcao, t));
}
};
/// Material de um objeto.
const Material = struct {
cor: Cor,
difuso: f64, // coeficiente de reflexão difusa
especular: f64, // coeficiente especular
brilho: f64, // expoente especular (shininess)
reflexao: f64, // coeficiente de reflexão (0 = fosco, 1 = espelho)
};
/// Esfera no espaço 3D.
const Esfera = struct {
centro: Vec3,
raio: f64,
material: Material,
};
/// Resultado de uma interseção raio-esfera.
const Intersecao = struct {
t: f64, // distância ao ponto de interseção
ponto: Vec3, // ponto de interseção
normal: Vec3, // normal da superfície no ponto
material: Material,
};
/// Testa interseção de um raio com uma esfera.
/// Retorna a distância t ou null se não houver interseção.
fn intersecaoRaioEsfera(raio: Raio, esfera: Esfera) ?f64 {
const oc = Vec3.sub(raio.origem, esfera.centro);
const a = Vec3.dot(raio.direcao, raio.direcao);
const b = 2.0 * Vec3.dot(oc, raio.direcao);
const c = Vec3.dot(oc, oc) - esfera.raio * esfera.raio;
const discriminante = b * b - 4.0 * a * c;
if (discriminante < 0) return null;
const sqrt_d = @sqrt(discriminante);
var t = (-b - sqrt_d) / (2.0 * a);
if (t < 0.001) {
t = (-b + sqrt_d) / (2.0 * a);
}
if (t < 0.001) return null;
return t;
}
/// Luz pontual na cena.
const Luz = struct {
posicao: Vec3,
cor: Cor,
intensidade: f64,
};
/// Cena completa com objetos e luzes.
const Cena = struct {
esferas: []const Esfera,
luzes: []const Luz,
cor_fundo: Cor,
ambiente: f64, // intensidade da luz ambiente
};
Passo 4: O Ray Tracer
/// Encontra a interseção mais próxima de um raio com os objetos da cena.
fn intersecarCena(raio: Raio, cena: Cena) ?Intersecao {
var mais_perto: ?Intersecao = null;
var t_min: f64 = math.inf(f64);
for (cena.esferas) |esfera| {
if (intersecaoRaioEsfera(raio, esfera)) |t| {
if (t < t_min) {
t_min = t;
const ponto = raio.ponto(t);
const normal = Vec3.normalize(Vec3.sub(ponto, esfera.centro));
mais_perto = .{
.t = t,
.ponto = ponto,
.normal = normal,
.material = esfera.material,
};
}
}
}
return mais_perto;
}
/// Calcula a cor de um pixel traçando um raio na cena.
fn tracerRaio(raio: Raio, cena: Cena, profundidade: u32) Cor {
if (profundidade == 0) return Vec3.zero();
const inter = intersecarCena(raio, cena) orelse return cena.cor_fundo;
var cor_final = Vec3.mul(inter.material.cor, cena.ambiente);
// Calcular iluminação para cada luz
for (cena.luzes) |luz| {
const dir_luz = Vec3.normalize(Vec3.sub(luz.posicao, inter.ponto));
const dist_luz = Vec3.length(Vec3.sub(luz.posicao, inter.ponto));
// Verificar sombra
const raio_sombra = Raio{
.origem = Vec3.add(inter.ponto, Vec3.mul(inter.normal, 0.001)),
.direcao = dir_luz,
};
var em_sombra = false;
for (cena.esferas) |esfera| {
if (intersecaoRaioEsfera(raio_sombra, esfera)) |t| {
if (t < dist_luz) {
em_sombra = true;
break;
}
}
}
if (!em_sombra) {
// Iluminação difusa (Lambert)
const n_dot_l = @max(0.0, Vec3.dot(inter.normal, dir_luz));
const difusa = Vec3.mul(
Vec3.mulVec(inter.material.cor, luz.cor),
inter.material.difuso * n_dot_l * luz.intensidade,
);
cor_final = Vec3.add(cor_final, difusa);
// Iluminação especular (Phong)
const reflexao_luz = Vec3.reflect(Vec3.mul(dir_luz, -1.0), inter.normal);
const dir_camera = Vec3.normalize(Vec3.mul(raio.direcao, -1.0));
const r_dot_v = @max(0.0, Vec3.dot(reflexao_luz, dir_camera));
const especular = Vec3.mul(
luz.cor,
inter.material.especular * math.pow(f64, r_dot_v, inter.material.brilho) * luz.intensidade,
);
cor_final = Vec3.add(cor_final, especular);
}
}
// Reflexão
if (inter.material.reflexao > 0 and profundidade > 1) {
const dir_reflexao = Vec3.reflect(raio.direcao, inter.normal);
const raio_reflexao = Raio{
.origem = Vec3.add(inter.ponto, Vec3.mul(inter.normal, 0.001)),
.direcao = dir_reflexao,
};
const cor_reflexao = tracerRaio(raio_reflexao, cena, profundidade - 1);
cor_final = Vec3.add(
Vec3.mul(cor_final, 1.0 - inter.material.reflexao),
Vec3.mul(cor_reflexao, inter.material.reflexao),
);
}
return Vec3.clamp01(cor_final);
}
Passo 5: Renderização e Saída PPM
const LARGURA = 800;
const ALTURA = 600;
pub fn main() !void {
const stdout = io.getStdOut().writer();
try stdout.print(
\\
\\ ==========================================
\\ RAY TRACER - Zig
\\ ==========================================
\\ Resolucao: {d}x{d}
\\ Renderizando...
\\
, .{ LARGURA, ALTURA });
// Definir cena
const esferas = [_]Esfera{
.{
.centro = Vec3.init(0, -1, 8),
.raio = 2.0,
.material = .{
.cor = Cor.init(0.9, 0.2, 0.2), // vermelho
.difuso = 0.8,
.especular = 0.5,
.brilho = 32.0,
.reflexao = 0.2,
},
},
.{
.centro = Vec3.init(-3, 0, 10),
.raio = 2.5,
.material = .{
.cor = Cor.init(0.2, 0.8, 0.2), // verde
.difuso = 0.7,
.especular = 0.3,
.brilho = 16.0,
.reflexao = 0.1,
},
},
.{
.centro = Vec3.init(3, -0.5, 7),
.raio = 1.5,
.material = .{
.cor = Cor.init(0.2, 0.3, 0.9), // azul
.difuso = 0.6,
.especular = 0.8,
.brilho = 64.0,
.reflexao = 0.5,
},
},
.{
// Chão (esfera grande)
.centro = Vec3.init(0, 102, 10),
.raio = 100.0,
.material = .{
.cor = Cor.init(0.7, 0.7, 0.7), // cinza
.difuso = 0.9,
.especular = 0.1,
.brilho = 8.0,
.reflexao = 0.05,
},
},
};
const luzes = [_]Luz{
.{
.posicao = Vec3.init(-10, -10, 2),
.cor = Cor.init(1, 1, 1),
.intensidade = 1.0,
},
.{
.posicao = Vec3.init(10, -8, 5),
.cor = Cor.init(0.5, 0.5, 1.0),
.intensidade = 0.6,
},
};
const cena = Cena{
.esferas = &esferas,
.luzes = &luzes,
.cor_fundo = Cor.init(0.1, 0.1, 0.2), // azul escuro
.ambiente = 0.1,
};
// Câmera
const camera_pos = Vec3.init(0, -2, 0);
const fov: f64 = 60.0;
const aspecto: f64 = @as(f64, @floatFromInt(LARGURA)) / @as(f64, @floatFromInt(ALTURA));
const fov_rad = fov * math.pi / 180.0;
const escala = @tan(fov_rad / 2.0);
// Renderizar para buffer
var pixels: [ALTURA][LARGURA][3]u8 = undefined;
const inicio = std.time.milliTimestamp();
for (0..ALTURA) |y| {
for (0..LARGURA) |x| {
// Converter coordenadas de pixel para coordenadas de câmera
const px = (2.0 * (@as(f64, @floatFromInt(x)) + 0.5) /
@as(f64, @floatFromInt(LARGURA)) - 1.0) * aspecto * escala;
const py = (1.0 - 2.0 * (@as(f64, @floatFromInt(y)) + 0.5) /
@as(f64, @floatFromInt(ALTURA))) * escala;
const direcao = Vec3.normalize(Vec3.init(px, py, 1.0));
const raio = Raio{ .origem = camera_pos, .direcao = direcao };
const cor = tracerRaio(raio, cena, 5);
pixels[y][x][0] = @intFromFloat(cor.x * 255.0);
pixels[y][x][1] = @intFromFloat(cor.y * 255.0);
pixels[y][x][2] = @intFromFloat(cor.z * 255.0);
}
// Progresso
if (y % 50 == 0) {
try stdout.print(" {d}%...\n", .{y * 100 / ALTURA});
}
}
const duracao: u64 = @intCast(std.time.milliTimestamp() - inicio);
// Salvar em PPM
const arquivo = try fs.cwd().createFile("cena.ppm", .{});
defer arquivo.close();
const writer = arquivo.writer();
// Header PPM
try writer.print("P3\n{d} {d}\n255\n", .{ LARGURA, ALTURA });
// Pixels
for (0..ALTURA) |y| {
for (0..LARGURA) |x| {
try writer.print("{d} {d} {d}\n", .{
pixels[y][x][0], pixels[y][x][1], pixels[y][x][2],
});
}
}
try stdout.print(
\\
\\ Renderizacao completa!
\\ Tempo: {d}ms
\\ Arquivo: cena.ppm ({d}x{d})
\\
\\ Para visualizar:
\\ - Linux: eog cena.ppm / feh cena.ppm
\\ - macOS: open cena.ppm
\\ - Converter: convert cena.ppm cena.png
\\
, .{ duracao, LARGURA, ALTURA });
}
Testes
test "vec3 - operacoes basicas" {
const a = Vec3.init(1, 2, 3);
const b = Vec3.init(4, 5, 6);
const soma = Vec3.add(a, b);
try std.testing.expectApproxEqAbs(@as(f64, 5.0), soma.x, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 7.0), soma.y, 0.001);
const dot_val = Vec3.dot(a, b);
try std.testing.expectApproxEqAbs(@as(f64, 32.0), dot_val, 0.001);
}
test "vec3 - normalize" {
const v = Vec3.init(3, 4, 0);
const n = Vec3.normalize(v);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), Vec3.length(n), 0.001);
}
test "intersecao raio-esfera" {
const raio = Raio{
.origem = Vec3.init(0, 0, 0),
.direcao = Vec3.init(0, 0, 1),
};
const esfera = Esfera{
.centro = Vec3.init(0, 0, 5),
.raio = 1.0,
.material = .{
.cor = Cor.init(1, 0, 0),
.difuso = 0.8,
.especular = 0.5,
.brilho = 32,
.reflexao = 0,
},
};
const t = intersecaoRaioEsfera(raio, esfera);
try std.testing.expect(t != null);
try std.testing.expectApproxEqAbs(@as(f64, 4.0), t.?, 0.001);
}
test "intersecao - miss" {
const raio = Raio{
.origem = Vec3.init(0, 0, 0),
.direcao = Vec3.init(0, 1, 0), // apontando para cima
};
const esfera = Esfera{
.centro = Vec3.init(0, 0, 5), // à frente
.raio = 1.0,
.material = .{
.cor = Cor.init(1, 0, 0),
.difuso = 0.8,
.especular = 0.5,
.brilho = 32,
.reflexao = 0,
},
};
try std.testing.expect(intersecaoRaioEsfera(raio, esfera) == null);
}
test "vec3 - reflect" {
const v = Vec3.init(1, -1, 0);
const n = Vec3.init(0, 1, 0);
const r = Vec3.reflect(v, n);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.x, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.y, 0.001);
}
Compilando e Executando
# Renderizar a cena
zig build run
# Visualizar (Linux)
eog cena.ppm
# Converter para PNG
convert cena.ppm cena.png
# Rodar testes
zig build test
Conceitos Aprendidos
- Ray tracing com traçado de raios para cada pixel
- Álgebra linear vetorial (dot, normalize, reflect)
- Iluminação de Phong (ambiente + difusa + especular)
- Sombras com raios de sombra secundários
- Reflexões recursivas com limite de profundidade
- Formato PPM para saída de imagem simples
Próximos Passos
- Explore o Processador BMP para outro formato de imagem
- Veja o Game of Life para renderização no terminal
- Construa a Thread Pool para renderização paralela
- Consulte matemática na stdlib para funções adicionais