Ray Tracer em Zig — Tutorial Passo a Passo

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

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

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.