Gerador de Fractais Mandelbrot em Rust

Construa um renderizador de fractais Mandelbrot em Rust com números complexos, colorização, zoom, renderização paralela com rayon e saída PNG.

Neste projeto vamos construir um renderizador de fractais do conjunto de Mandelbrot em Rust. O conjunto de Mandelbrot é provavelmente o fractal mais famoso da matemática – uma estrutura infinitamente complexa gerada por uma fórmula surpreendentemente simples: z = z² + c. Cada pixel da imagem representa um número complexo, e a cor indica quantas iterações a sequência leva para “escapar” (divergir para o infinito).

Este é um projeto ideal para explorar computação numérica, paralelismo e geração de imagens em Rust. A renderização de cada pixel é independente, o que permite paralelização perfeita com rayon. Ao final, você poderá gerar imagens detalhadas com zoom em regiões fascinantes do fractal e experimentar diferentes esquemas de cores.

O Que Vamos Construir

Um renderizador de Mandelbrot com as seguintes funcionalidades:

  • Aritmética de números complexos implementada do zero
  • Cálculo de pertinência ao conjunto com contagem de iterações
  • Mapeamento de coordenadas de pixels para o plano complexo
  • Múltiplos esquemas de colorização (escala de cinza, suave, espectro)
  • Suporte a zoom em regiões específicas do fractal
  • Renderização paralela com rayon para máxima performance
  • Saída em formatos PPM e PNG

Estrutura do Projeto

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

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new mandelbrot
cd mandelbrot

Edite o Cargo.toml:

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

[dependencies]
rayon = "1.10"
image = "0.25"

A crate rayon permite paralelizar o cálculo com uma única mudança (into_par_iter), distribuindo as linhas entre todos os núcleos. A crate image é usada para salvar a saída em formato PNG.

Passo 1: Números Complexos

A base do Mandelbrot é a aritmética de números complexos. Um número complexo tem parte real e parte imaginária: c = a + bi. Vamos implementar uma struct com todas as operações necessárias.

use image::{ImageBuffer, Rgb};
use rayon::prelude::*;
use std::env;

/// Número complexo com parte real e imaginária (f64)
#[derive(Clone, Copy, Debug)]
struct Complexo {
    real: f64,
    imag: f64,
}

impl Complexo {
    /// Cria um novo número complexo
    fn novo(real: f64, imag: f64) -> Self {
        Complexo { real, imag }
    }

    /// Retorna o quadrado do módulo (|z|² = a² + b²)
    /// Usamos o quadrado para evitar calcular raiz quadrada
    fn modulo_quadrado(&self) -> f64 {
        self.real * self.real + self.imag * self.imag
    }

    /// Multiplica dois números complexos:
    /// (a + bi)(c + di) = (ac - bd) + (ad + bc)i
    fn multiplicar(&self, outro: &Complexo) -> Complexo {
        Complexo {
            real: self.real * outro.real - self.imag * outro.imag,
            imag: self.real * outro.imag + self.imag * outro.real,
        }
    }

    /// Soma dois números complexos:
    /// (a + bi) + (c + di) = (a + c) + (b + d)i
    fn somar(&self, outro: &Complexo) -> Complexo {
        Complexo {
            real: self.real + outro.real,
            imag: self.imag + outro.imag,
        }
    }

    /// Eleva ao quadrado: z² = z * z
    fn ao_quadrado(&self) -> Complexo {
        self.multiplicar(self)
    }
}

Usamos modulo_quadrado em vez de modulo (que exigiria sqrt) porque para o teste de escape precisamos apenas verificar se |z|² > 4 (equivalente a |z| > 2). Evitar a raiz quadrada em cada iteração de cada pixel faz uma diferença significativa na performance.

Passo 2: Cálculo do Mandelbrot e Colorização

Agora implementamos o coração do algoritmo: para cada ponto c no plano complexo, iteramos z = z² + c começando com z = 0 e contamos quantas iterações são necessárias antes que |z| > 2 (escape). Também implementamos diferentes esquemas de colorização.

/// Configuração da renderização
struct ConfigRender {
    /// Largura da imagem em pixels
    largura: u32,
    /// Altura da imagem em pixels
    altura: u32,
    /// Número máximo de iterações por pixel
    max_iteracoes: u32,
    /// Canto superior esquerdo do plano complexo visível
    min_real: f64,
    max_real: f64,
    min_imag: f64,
    max_imag: f64,
    /// Esquema de cores a usar
    esquema_cores: EsquemaCores,
}

/// Esquemas de colorização disponíveis
#[derive(Clone, Copy, Debug)]
enum EsquemaCores {
    /// Tons de cinza simples
    EscalaCinza,
    /// Transição suave com interpolação
    Suave,
    /// Espectro de cores vibrantes baseado em seno
    Espectro,
}

/// Calcula quantas iterações o ponto c leva para escapar.
/// Retorna (iteracoes, |z|² final) para colorização suave.
fn calcular_escape(c: &Complexo, max_iteracoes: u32) -> (u32, f64) {
    let mut z = Complexo::novo(0.0, 0.0);

    for i in 0..max_iteracoes {
        // z = z² + c
        z = z.ao_quadrado().somar(c);

        // Teste de escape: |z|² > 4 (equivale a |z| > 2)
        let mod_q = z.modulo_quadrado();
        if mod_q > 4.0 {
            return (i, mod_q);
        }
    }

    // Não escapou: pertence ao conjunto de Mandelbrot
    (max_iteracoes, z.modulo_quadrado())
}

/// Converte coordenadas de pixel para um ponto no plano complexo
fn pixel_para_complexo(
    x: u32,
    y: u32,
    config: &ConfigRender,
) -> Complexo {
    let real = config.min_real
        + (x as f64 / config.largura as f64) * (config.max_real - config.min_real);
    let imag = config.min_imag
        + (y as f64 / config.altura as f64) * (config.max_imag - config.min_imag);
    Complexo::novo(real, imag)
}

/// Colorização em escala de cinza
fn cor_escala_cinza(iteracoes: u32, max_iteracoes: u32) -> [u8; 3] {
    if iteracoes == max_iteracoes {
        return [0, 0, 0]; // Preto para pontos no conjunto
    }
    let brilho = (255.0 * iteracoes as f64 / max_iteracoes as f64) as u8;
    [brilho, brilho, brilho]
}

/// Colorização suave usando interpolação logarítmica
fn cor_suave(iteracoes: u32, max_iteracoes: u32, mod_quadrado: f64) -> [u8; 3] {
    if iteracoes == max_iteracoes {
        return [0, 0, 0]; // Preto para pontos no conjunto
    }

    // Suavização: usa o módulo para interpolar entre iterações inteiras
    let suave = iteracoes as f64 + 1.0 - mod_quadrado.ln().ln() / 2.0_f64.ln();
    let t = suave / max_iteracoes as f64;

    // Paleta baseada em interpolação de Bernstein
    let r = (9.0 * (1.0 - t) * t * t * t * 255.0) as u8;
    let g = (15.0 * (1.0 - t) * (1.0 - t) * t * t * 255.0) as u8;
    let b = (8.5 * (1.0 - t) * (1.0 - t) * (1.0 - t) * t * 255.0) as u8;

    [r, g, b]
}

/// Colorização por espectro usando funções seno
fn cor_espectro(iteracoes: u32, max_iteracoes: u32) -> [u8; 3] {
    if iteracoes == max_iteracoes {
        return [0, 0, 0]; // Preto para pontos no conjunto
    }

    let t = iteracoes as f64 / max_iteracoes as f64;

    // Oscila por diferentes faixas de cor usando seno com defasagens
    let r = (0.5 + 0.5 * (std::f64::consts::TAU * (t * 5.0 + 0.0)).sin()) * 255.0;
    let g = (0.5 + 0.5 * (std::f64::consts::TAU * (t * 5.0 + 0.33)).sin()) * 255.0;
    let b = (0.5 + 0.5 * (std::f64::consts::TAU * (t * 5.0 + 0.67)).sin()) * 255.0;

    [r as u8, g as u8, b as u8]
}

/// Escolhe a cor de acordo com o esquema configurado
fn colorizar(
    iteracoes: u32,
    max_iteracoes: u32,
    mod_quadrado: f64,
    esquema: EsquemaCores,
) -> [u8; 3] {
    match esquema {
        EsquemaCores::EscalaCinza => cor_escala_cinza(iteracoes, max_iteracoes),
        EsquemaCores::Suave => cor_suave(iteracoes, max_iteracoes, mod_quadrado),
        EsquemaCores::Espectro => cor_espectro(iteracoes, max_iteracoes),
    }
}

A colorização suave usa uma técnica chamada “normalized iteration count”: em vez de usar a contagem inteira de iterações, usamos o logaritmo do módulo no ponto de escape para interpolar suavemente entre valores, eliminando as faixas de cor bruscas. O esquema de espectro usa funções seno defasadas em 120 graus para gerar um arco-íris completo.

Passo 3: Renderização Paralela com Rayon

Agora implementamos a renderização propriamente dita, usando rayon para processar as linhas em paralelo. Cada pixel é completamente independente dos demais, tornando este problema “embaraçosamente paralelo”.

/// Renderiza o fractal de Mandelbrot, retornando um vetor de pixels RGB
fn renderizar(config: &ConfigRender) -> Vec<[u8; 3]> {
    // Cada linha é processada independentemente em paralelo
    let pixels: Vec<[u8; 3]> = (0..config.altura)
        .into_par_iter()
        .flat_map(|y| {
            (0..config.largura)
                .map(|x| {
                    // Converte pixel para coordenada complexa
                    let c = pixel_para_complexo(x, y, config);

                    // Calcula o número de iterações para escape
                    let (iteracoes, mod_q) = calcular_escape(&c, config.max_iteracoes);

                    // Converte a contagem de iterações em cor
                    colorizar(iteracoes, config.max_iteracoes, mod_q, config.esquema_cores)
                })
                .collect::<Vec<_>>()
        })
        .collect();

    pixels
}

/// Salva os pixels como imagem PNG
fn salvar_png(
    caminho: &str,
    pixels: &[[u8; 3]],
    largura: u32,
    altura: u32,
) {
    let imagem = ImageBuffer::from_fn(largura, altura, |x, y| {
        let indice = (y * largura + x) as usize;
        let [r, g, b] = pixels[indice];
        Rgb([r, g, b])
    });

    imagem.save(caminho).expect("Falha ao salvar a imagem PNG");
}

/// Salva os pixels como imagem PPM (formato texto simples)
fn salvar_ppm(
    caminho: &str,
    pixels: &[[u8; 3]],
    largura: u32,
    altura: u32,
) {
    use std::fs::File;
    use std::io::{BufWriter, Write};

    let arquivo = File::create(caminho).expect("Falha ao criar o arquivo PPM");
    let mut escritor = BufWriter::new(arquivo);

    // Cabeçalho PPM
    writeln!(escritor, "P3").unwrap();
    writeln!(escritor, "{} {}", largura, altura).unwrap();
    writeln!(escritor, "255").unwrap();

    // Dados dos pixels
    for [r, g, b] in pixels {
        writeln!(escritor, "{} {} {}", r, g, b).unwrap();
    }
}

O into_par_iter() do rayon distribui automaticamente as linhas entre os núcleos disponíveis. Em um processador de 8 núcleos, a renderização é aproximadamente 7-8x mais rápida que a versão sequencial. O flat_map achata os vetores de pixels de cada linha em um único vetor contínuo.

Passo 4: Interface de Linha de Comando e Presets de Zoom

Finalmente, implementamos a função main com suporte a argumentos de linha de comando e presets de regiões interessantes do fractal para exploração.

/// Presets de regiões interessantes do fractal
fn obter_preset(nome: &str) -> Option<(f64, f64, f64, f64, u32)> {
    // (min_real, max_real, min_imag, max_imag, max_iteracoes)
    match nome {
        "completo" => Some((-2.5, 1.0, -1.25, 1.25, 256)),
        "seahorse" => Some((-0.75, -0.74, 0.09, 0.102, 512)),
        "espiral" => Some((-0.7463, -0.7413, 0.1102, 0.1152, 1024)),
        "miniatura" => Some((-1.769, -1.765, -0.001, 0.003, 2048)),
        "raio" => Some((-0.1592, -0.1505, -1.0328, -1.0255, 1024)),
        "elefante" => Some((0.2755, 0.2855, 0.006, 0.016, 512)),
        _ => None,
    }
}

/// Exibe as instruções de uso
fn exibir_uso() {
    eprintln!("Gerador de Fractais Mandelbrot em Rust");
    eprintln!();
    eprintln!("Uso:");
    eprintln!("  mandelbrot [opções]");
    eprintln!();
    eprintln!("Opções:");
    eprintln!("  --largura <N>         Largura da imagem em pixels (padrão: 800)");
    eprintln!("  --altura <N>          Altura da imagem em pixels (padrão: 600)");
    eprintln!("  --iteracoes <N>       Máximo de iterações (padrão: 256)");
    eprintln!("  --cores <esquema>     Esquema de cores: cinza, suave, espectro (padrão: suave)");
    eprintln!("  --preset <nome>       Região predefinida: completo, seahorse, espiral,");
    eprintln!("                        miniatura, raio, elefante");
    eprintln!("  --saida <arquivo>     Arquivo de saída (padrão: mandelbrot.png)");
    eprintln!("  --ppm                 Salva em formato PPM em vez de PNG");
    eprintln!();
    eprintln!("Exemplos:");
    eprintln!("  mandelbrot");
    eprintln!("  mandelbrot --preset seahorse --cores espectro");
    eprintln!("  mandelbrot --largura 1920 --altura 1080 --iteracoes 1000");
}

fn main() {
    let args: Vec<String> = env::args().collect();

    // Valores padrão
    let mut largura: u32 = 800;
    let mut altura: u32 = 600;
    let mut max_iteracoes: u32 = 256;
    let mut min_real = -2.5;
    let mut max_real = 1.0;
    let mut min_imag = -1.25;
    let mut max_imag = 1.25;
    let mut esquema_cores = EsquemaCores::Suave;
    let mut caminho_saida = String::from("mandelbrot.png");
    let mut formato_ppm = false;

    // Analisa argumentos
    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--largura" => {
                i += 1;
                largura = args[i].parse().expect("Largura deve ser um número");
            }
            "--altura" => {
                i += 1;
                altura = args[i].parse().expect("Altura deve ser um número");
            }
            "--iteracoes" => {
                i += 1;
                max_iteracoes = args[i].parse().expect("Iterações deve ser um número");
            }
            "--cores" => {
                i += 1;
                esquema_cores = match args[i].as_str() {
                    "cinza" => EsquemaCores::EscalaCinza,
                    "suave" => EsquemaCores::Suave,
                    "espectro" => EsquemaCores::Espectro,
                    outro => {
                        eprintln!("Esquema de cores desconhecido: '{}'. Usando 'suave'.", outro);
                        EsquemaCores::Suave
                    }
                };
            }
            "--preset" => {
                i += 1;
                match obter_preset(&args[i]) {
                    Some((rmin, rmax, imin, imax, iter)) => {
                        min_real = rmin;
                        max_real = rmax;
                        min_imag = imin;
                        max_imag = imax;
                        max_iteracoes = iter;
                    }
                    None => {
                        eprintln!(
                            "Preset desconhecido: '{}'. Presets: completo, seahorse, espiral, miniatura, raio, elefante",
                            args[i]
                        );
                        std::process::exit(1);
                    }
                }
            }
            "--saida" => {
                i += 1;
                caminho_saida = args[i].clone();
            }
            "--ppm" => {
                formato_ppm = true;
            }
            "--ajuda" | "-h" => {
                exibir_uso();
                return;
            }
            _ => {
                eprintln!("Opção desconhecida: '{}'", args[i]);
                exibir_uso();
                std::process::exit(1);
            }
        }
        i += 1;
    }

    // Ajusta a extensão do arquivo se necessário
    if formato_ppm && caminho_saida == "mandelbrot.png" {
        caminho_saida = String::from("mandelbrot.ppm");
    }

    let config = ConfigRender {
        largura,
        altura,
        max_iteracoes,
        min_real,
        max_real,
        min_imag,
        max_imag,
        esquema_cores,
    };

    println!("Gerando fractal de Mandelbrot...");
    println!("  Resolução:    {}x{}", largura, altura);
    println!("  Iterações:    {}", max_iteracoes);
    println!(
        "  Região:       [{:.6}, {:.6}] x [{:.6}, {:.6}]",
        min_real, max_real, min_imag, max_imag
    );
    println!("  Cores:        {:?}", esquema_cores);

    let inicio = std::time::Instant::now();

    // Renderiza o fractal em paralelo
    let pixels = renderizar(&config);

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

    // Salva a imagem
    if formato_ppm {
        salvar_ppm(&caminho_saida, &pixels, largura, altura);
    } else {
        salvar_png(&caminho_saida, &pixels, largura, altura);
    }

    let total_pixels = largura as u64 * altura as u64;
    let pixels_por_segundo = total_pixels as f64 / duracao.as_secs_f64();
    println!("  Pixels/seg:   {:.0}", pixels_por_segundo);
    println!("  Salvo em:     '{}'", caminho_saida);
}

Os presets de zoom foram cuidadosamente escolhidos para destacar diferentes estruturas do fractal: “seahorse” mostra o famoso vale dos cavalos-marinhos, “espiral” revela espirais de Fibonacci, “miniatura” mostra uma cópia em miniatura do conjunto principal, e “elefante” exibe o vale dos elefantes. Cada preset ajusta também o número máximo de iterações, já que regiões com mais zoom precisam de mais iterações para revelar os detalhes.

Como Executar

Compile e execute o projeto:

# Renderização padrão (visão completa do Mandelbrot)
cargo run --release

# Saída esperada:
# Gerando fractal de Mandelbrot...
#   Resolução:    800x600
#   Iterações:    256
#   Região:       [-2.500000, 1.000000] x [-1.250000, 1.250000]
#   Cores:        Suave
#   Renderização: 0.15s
#   Pixels/seg:   3200000
#   Salvo em:     'mandelbrot.png'

# Zoom na região seahorse com cores de espectro
cargo run --release -- --preset seahorse --cores espectro

# Imagem em alta resolução
cargo run --release -- --largura 3840 --altura 2160 --iteracoes 1000

# Zoom na espiral com saída PPM
cargo run --release -- --preset espiral --ppm

# Para converter PPM em PNG (requer ImageMagick)
convert mandelbrot.ppm mandelbrot.png

O modo --release é essencial para performance – a diferença entre debug e release pode ser de 10-20x em cálculos de ponto flutuante.

Desafios para Expandir

  1. Conjunto de Julia – Implemente a renderização de conjuntos de Julia, que usam a mesma fórmula z = z² + c mas com c fixo e z variando. Cada ponto c do Mandelbrot corresponde a um conjunto de Julia diferente, criando uma galeria infinita de fractais.

  2. Zoom interativo – Use a crate minifb ou sdl2 para criar uma janela onde o usuário pode clicar para centralizar e usar a roda do mouse para dar zoom, renderizando em tempo real.

  3. Precisão arbitrária – Substitua f64 por uma biblioteca de precisão arbitrária (como rug ou num-bigfloat) para permitir zooms extremos além do limite de 10⁻¹⁵ do ponto flutuante de 64 bits.

  4. Animação de zoom – Gere uma sequência de frames que fazem zoom progressivo em uma região interessante, salvando como série de PNGs que podem ser combinados em um vídeo com ffmpeg.

  5. Perturbation theory – Implemente o método de perturbação que calcula apenas um ponto de referência com precisão arbitrária e deriva os demais usando perturbações em f64, acelerando dramaticamente zooms profundos.

Veja Também