Фракталы — это бесконечные самоподобные фигуры. Они определяются простыми математическими формулами, которые создают удивительную красоту!

В этой статье мы рассмотрим алгоритм визуализации одного из самых известных фракталов на языке Rust с аппаратным ускорением NVIDIA, масштабированием, сглаживанием и многопоточностью.

Множество Мандельброта

Пожалуй, это самый известный фрактал, описанный в 1905 году Пьером Фату. Множество задаётся следующей формулой, где z и C — комплексные числа:

f(z) = z^2 + C

Фрактал можно визуализировать последовательным вычислением функции f(z), начиная с z_0. Доказано, что множество ограничено окружностью с радиусом 2. Если за n итераций точка z осталась внутри окружности (|z| < 2), то считаем, что z принадлежит множеству и закрашиваем её в чёрный цвет.

Реализация на CPU

Для начала давайте напишем программу для визуализации набора без использования NVIDIA CUDA. Создадим пустой проект с помощью cargo init:

$ cargo init mandelbrot_set & cd mandelbrot_set
$ tree
├── Cargo.lock
├── Cargo.toml
├── README.md
└── src
    └── main.rs

В Cargo.toml добавим следующие зависимости:

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

[dependencies]
+num = "0.4.3"
+image = "0.25.1"
  • image для работы с изображениями

  • num для поддержки комплексных чисел

Импортируем эти библиотеки:

use image::{Rgb, RgbImage};
use num::complex::Complex;

Напишем функцию для f(z) = z^2 + C:

fn num_iters(cx: f64, cy: f64, max_iters: u32) -> u32 {
    let mut z = Complex::new(0.0, 0.0);
    let c = Complex::new(cx, cy);

    for i in 0..=max_iters {
        if z.norm() > 2.0 {
            return i;
        }
        z = z * z + c;
    }

    max_iters
}

Обозначим функцию generate_set, которая сохранит визуализацию множества в файл:

fn generate_set(
    file_name: String,
    max_iters: u32,
    x_min: f64,
    y_min: f64,
    x_max: f64,
    y_max: f64,
    resolution: u32,
) { }

Создадим и сохраним пустое RGB изображение:

let mut buffer = RgbImage::new(resolution, resolution);
buffer.save(&file_name).unwrap();

Рассчитаем количество итераций из num_iters для каждого пикселя изображения:

for x in 0..resolution {
    for y in 0..resolution {
        let x_percent = x as f64 / resolution as f64;
        let y_percent = y as f64 / resolution as f64;
        let cx = x_min + (x_max - x_min) * x_percent;
        let cy = y_min + (y_max - y_min) * y_percent;
        let iters = num_iters(cx, cy, max_iters);
    }
}

Если предел итераций достигнут, мы оставляем пиксель по умолчанию черным. В противном случае закрашиваем его белым цветом:

let pixel = buffer.get_pixel_mut(x, y);
if iters < max_iters {
    *pixel = Rgb([255, 255, 255]);
}
Полный код функции generate_set
fn generate_set(
    file_name: String,
    max_iters: u32,
    x_min: f64,
    y_min: f64,
    x_max: f64,
    y_max: f64,
    resolution: u32,
) {
    let mut buffer = RgbImage::new(resolution, resolution);

    for x in 0..resolution {
        for y in 0..resolution {
            let x_percent = x as f64 / resolution as f64;
            let y_percent = y as f64 / resolution as f64;
            let cx = x_min + (x_max - x_min) * x_percent;
            let cy = y_min + (y_max - y_min) * y_percent;
            let iters = num_iters(cx, cy, max_iters);
            let pixel = buffer.get_pixel_mut(x, y);

            if iters < max_iters {
                *pixel = Rgb([255, 255, 255]);
            }
        }
    }

    buffer.save(&file_name).unwrap();
}

Вызовем функцию из main:

fn main() {
    let resolution = 1024;;
    let max_iters = 100;

    generate_set(
        "fractal.png".to_string(),
        max_iters,
        -1.5,
        -1.0,
        0.5,
        1.0,
        resolution,
    );
}
Полный код main.rs
use image::{Rgb, RgbImage};
use num::complex::Complex;


fn generate_set(
    file_name: String,
    max_iters: u32,
    x_min: f64,
    y_min: f64,
    x_max: f64,
    y_max: f64,
    resolution: u32,
) {
    let mut buffer = RgbImage::new(resolution, resolution);

    for x in 0..resolution {
        for y in 0..resolution {
            let x_percent = x as f64 / resolution as f64;
            let y_percent = y as f64 / resolution as f64;
            let cx = x_min + (x_max - x_min) * x_percent;
            let cy = y_min + (y_max - y_min) * y_percent;
            let iters = num_iters(cx, cy, max_iters);
            let pixel = buffer.get_pixel_mut(x, y);

            if iters < max_iters {
                *pixel = Rgb([255, 255, 255]);
            }
        }
    }

    buffer.save(&file_name).unwrap();
}

fn num_iters(cx: f64, cy: f64, max_iters: u32) -> u32 {
    let mut z = Complex::new(0.0, 0.0);
    let c = Complex::new(cx, cy);

    for i in 0..=max_iters {
        if z.norm() > 2.0 {
            return i;
        }
        z = z * z + c;
    }

    max_iters
}

fn main() {
    let resolution = 1024;;
    let max_iters = 100;

    generate_set(
        "fractal.png".to_string(),
        max_iters,
        -1.5,
        -1.0,
        0.5,
        1.0,
        resolution,
    );
}

Запустим программу — cargo run --release. При первом запуске необходимо подождать, пока библиотеки скомпилируются.

fractal.png
fractal.png

В результате получается такой фрактал. Строго математически, это правильная визуализация — точка либо принадлежит множеству (черный цвет), либо нет (белый цвет), но никто не мешает нам добавить немного цвета! Один из способов раскрасить фрактал — рассмотреть, как быстро становится понятно, что точка не принадлежит множеству.

Добавим цвета

Пусть имеется массив gradient с max_iters элементов, в котором будут заключены различные цвета. Если iters < max_iters, то мы берём gradient[iters] и окрашиваем пиксель в этот цвет. В противном случае оставляем точку черной:

fn generate_set(
    ...
    colors: Vec<&str>,
    ...
) {
  ...
  let gradient = get_gradient(colors, max_iters);
  ...
  for x in 0..resolution {
      for y in 0..resolution {
          ...
          let pixel = buffer.get_pixel_mut(x, y);
          let color = gradient.get(iters as usize).unwrap_or(&[0, 0, 0]);
          *pixel = Rgb(*color);
      }
  }
}

Здесь реализованы функции для создания линейного градиента из нескольких цветов HEX:

hex2rgb, lerp_color и get_gradient
fn hex2rgb(hex: &str) -> Result<Vec<u8>, String> {
    let hex = hex.trim_start_matches('#');
    if hex.len() != 6 {
        return Err("Invalid HEX color length".to_string());
    }

    let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid HEX color")?;
    let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid HEX color")?;
    let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid HEX color")?;

    Ok(vec![r, g, b])
}

fn lerp_color(color1: &[u8; 3], color2: &[u8; 3], value: f64) -> [u8; 3] {
    [
        (color1[0] as f64 + (color2[0] as f64 - color1[0] as f64) * value) as u8,
        (color1[1] as f64 + (color2[1] as f64 - color1[1] as f64) * value) as u8,
        (color1[2] as f64 + (color2[2] as f64 - color1[2] as f64) * value) as u8,
    ]
}

fn get_gradient(gradient_colors: Vec<&str>, max_iters: u32) -> Vec<[u8; 3]> {
    let mut colors = vec![];
    let mut gradient_colors_rgb = vec![];
    for color in &gradient_colors {
        let rgb = hex2rgb(color).unwrap();
        gradient_colors_rgb.push([rgb[0], rgb[1], rgb[2]]);
    }

    for i in 0..max_iters {
        let color_index = (i as usize * (gradient_colors.len() - 1)) / max_iters as usize;
        let color_value = (i as f64 * (gradient_colors.len() as f64 - 1.0)) / max_iters as f64;
        let value = color_value % 1.0;
        colors.push(lerp_color(
            &gradient_colors_rgb[color_index],
            &gradient_colors_rgb[color_index + 1],
            value,
        ));
    }

    colors
}

Создавать цветовые палитры можно на этом сайте - https://color.adobe.com/

Полный код main.rs
use image::{Rgb, RgbImage};
use num::complex::Complex;

fn hex2rgb(hex: &str) -> Result<Vec<u8>, String> {
    let hex = hex.trim_start_matches('#');
    if hex.len() != 6 {
        return Err("Invalid HEX color length".to_string());
    }

    let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid HEX color")?;
    let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid HEX color")?;
    let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid HEX color")?;

    Ok(vec![r, g, b])
}

fn lerp_color(color1: &[u8; 3], color2: &[u8; 3], value: f64) -> [u8; 3] {
    [
        (color1[0] as f64 + (color2[0] as f64 - color1[0] as f64) * value) as u8,
        (color1[1] as f64 + (color2[1] as f64 - color1[1] as f64) * value) as u8,
        (color1[2] as f64 + (color2[2] as f64 - color1[2] as f64) * value) as u8,
    ]
}

fn get_gradient(gradient_colors: Vec<&str>, max_iters: u32) -> Vec<[u8; 3]> {
    let mut colors = vec![];
    let mut gradient_colors_rgb = vec![];
    for color in &gradient_colors {
        let rgb = hex2rgb(color).unwrap();
        gradient_colors_rgb.push([rgb[0], rgb[1], rgb[2]]);
    }

    for i in 0..max_iters {
        let color_index = (i as usize * (gradient_colors.len() - 1)) / max_iters as usize;
        let color_value = (i as f64 * (gradient_colors.len() as f64 - 1.0)) / max_iters as f64;
        let value = color_value % 1.0;
        colors.push(lerp_color(
            &gradient_colors_rgb[color_index],
            &gradient_colors_rgb[color_index + 1],
            value,
        ));
    }

    colors
}

fn generate_set(
    file_name: String,
    max_iters: u32,
    colors: Vec<&str>,
    x_min: f64,
    y_min: f64,
    x_max: f64,
    y_max: f64,
    resolution: u32,
) {
    let mut buffer = RgbImage::new(resolution, resolution);
    let gradient = get_gradient(colors, max_iters);

    for x in 0..resolution {
        for y in 0..resolution {
            let x_percent = x as f64 / resolution as f64;
            let y_percent = y as f64 / resolution as f64;
            let cx = x_min + (x_max - x_min) * x_percent;
            let cy = y_min + (y_max - y_min) * y_percent;
            let iters = num_iters(cx, cy, max_iters);
            let pixel = buffer.get_pixel_mut(x, y);
            let color = gradient.get(iters as usize).unwrap_or(&[0, 0, 0]);
            *pixel = Rgb(*color);
        }
    }

    buffer.save(&file_name).unwrap();
}

fn num_iters(cx: f64, cy: f64, max_iters: u32) -> u32 {
    let mut z = Complex::new(0.0, 0.0);
    let c = Complex::new(cx, cy);

    for i in 0..=max_iters {
        if z.norm() > 2.0 {
            return i;
        }
        z = z * z + c;
    }

    max_iters
}

fn main() {
    let resolution = 1024;
    let max_iters = 100;

    generate_set(
        "fractal.png".to_string(),
        max_iters,
        vec!["#1C448E", "#6F8695", "#CEC288", "#FFE381", "#DBFE87"],
        -1.5,
        -1.0,
        0.5,
        1.0,
        resolution,
    );
}

fractal.png
fractal.png

Переносим код на NVIDIA CUDA

Связать C++ с Rust можно через библиотеки libc и cc (источник). Добавим их в Cargo.toml:

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

[dependencies]
num = "0.4.3"
image = "0.25.1"
+libc = "0.2.155"

+[build-dependencies]
+cc = "1.0.98"

Создадим в папке src файлы mandelbrot_gpu.h, mandelbrot.cpp и mandelbrot_gpu.cu. В новых версиях CUDA встроена библиотека thrust/complex.h для нативной поддержки комплексных чисел. Перепишем функцию num_iters на CUDA:

#include <thrust/complex.h>

_device__ unsigned int num_iters(double cx, double cy, unsigned int max_iters) {
    thrust::complex<double> z(0.0, 0.0);
    thrust::complex<double> c(cx, cy);

    for (unsigned int i = 0; i <= max_iters; ++i) {
        if (thrust::abs(z) > 2.0) {
            return i;
        }
        z = z * z + c;
    }

    return max_iters;
}

Создадим функцию gpu_mandelbrot, которая в многопоточном режиме просчитает количество итераций для каждого пикселя изображения:

__global__ void mandelbrot_kernel(unsigned int* results, double* cx, double* cy, unsigned int max_iters, int num_points) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < num_points) {
        results[idx] = num_iters(cx[idx], cy[idx], max_iters);
    }
}

unsigned int* gpu_mandelbrot(double* cx, double* cy, size_t num_points, unsigned int max_iters) {
    unsigned int *dev_results, *results;
    double *dev_cx, *dev_cy;

    results = new unsigned int[num_points];

    cudaMalloc((void**)&dev_cx, num_points * sizeof(double));
    cudaMalloc((void**)&dev_cy, num_points * sizeof(double));
    cudaMalloc((void**)&dev_results, num_points * sizeof(unsigned int));

    cudaMemcpy(dev_cx, cx, num_points * sizeof(double), cudaMemcpyHostToDevice);
    cudaMemcpy(dev_cy, cy, num_points * sizeof(double), cudaMemcpyHostToDevice);

    int threads_per_block = 256;
    int blocks_per_grid = (num_points + threads_per_block - 1) / threads_per_block;

    mandelbrot_kernel<<<blocks_per_grid, threads_per_block>>>(dev_results, dev_cx, dev_cy, max_iters, num_points);

    cudaMemcpy(results, dev_results, num_points * sizeof(unsigned int), cudaMemcpyDeviceToHost);

    cudaFree(dev_cx);
    cudaFree(dev_cy);
    cudaFree(dev_results);

    return results;
}

В файле mandelbrot.cpp создадим основную функцию для вызова из Rust:

extern "C" {
    void calculate_mandelbrot(const double* cx, const double* cy, int num_points, unsigned int max_iters, unsigned int* output) {
        double* non_const_cx = const_cast<double*>(cx);
        double* non_const_cy = const_cast<double*>(cy);

        unsigned int* gpu_results = gpu_mandelbrot(non_const_cx, non_const_cy, num_points, max_iters);

        for (int i = 0; i < num_points; ++i) {
            output[i] = gpu_results[i];
        }

        delete[] gpu_results;
    }
}
Полный код

mandelbrot_gpu.h

#ifndef MANDELBROT_GPU_H
#define MANDELBROT_GPU_H

unsigned int* gpu_mandelbrot(double* cx, double* cy, size_t num_points, unsigned int max_iters);

#endif // MANDELBROT_GPU_H

mandelbrot_gpu.cu

#include <thrust/complex.h>
#include <cuda_runtime.h>
#include "mandelbrot_gpu.h"

__device__ unsigned int num_iters(double cx, double cy, unsigned int max_iters) {
    thrust::complex<double> z(0.0, 0.0);
    thrust::complex<double> c(cx, cy);

    for (unsigned int i = 0; i <= max_iters; ++i) {
        if (thrust::abs(z) > 2.0) {
            return i;
        }
        z = z * z + c;
    }

    return max_iters;
}

__global__ void mandelbrot_kernel(unsigned int* results, double* cx, double* cy, unsigned int max_iters, int num_points) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < num_points) {
        results[idx] = num_iters(cx[idx], cy[idx], max_iters);
    }
}

unsigned int* gpu_mandelbrot(double* cx, double* cy, size_t num_points, unsigned int max_iters) {
    unsigned int *dev_results, *results;
    double *dev_cx, *dev_cy;

    results = new unsigned int[num_points];

    cudaMalloc((void**)&dev_cx, num_points * sizeof(double));
    cudaMalloc((void**)&dev_cy, num_points * sizeof(double));
    cudaMalloc((void**)&dev_results, num_points * sizeof(unsigned int));

    cudaMemcpy(dev_cx, cx, num_points * sizeof(double), cudaMemcpyHostToDevice);
    cudaMemcpy(dev_cy, cy, num_points * sizeof(double), cudaMemcpyHostToDevice);

    int threads_per_block = 256;
    int blocks_per_grid = (num_points + threads_per_block - 1) / threads_per_block;

    mandelbrot_kernel<<<blocks_per_grid, threads_per_block>>>(dev_results, dev_cx, dev_cy, max_iters, num_points);

    cudaMemcpy(results, dev_results, num_points * sizeof(unsigned int), cudaMemcpyDeviceToHost);

    cudaFree(dev_cx);
    cudaFree(dev_cy);
    cudaFree(dev_results);

    return results;
}

mandelbrot.cpp

#include <iostream>
#include "mandelbrot_gpu.h"

extern "C" {
    void calculate_mandelbrot(const double* cx, const double* cy, int num_points, unsigned int max_iters, unsigned int* output) {
        double* non_const_cx = const_cast<double*>(cx);
        double* non_const_cy = const_cast<double*>(cy);

        unsigned int* gpu_results = gpu_mandelbrot(non_const_cx, non_const_cy, num_points, max_iters);

        for (int i = 0; i < num_points; ++i) {
            output[i] = gpu_results[i];
        }

        delete[] gpu_results;
    }
}

Создадим build.rs — файл, который соберёт и скомпилирует CUDA‑часть:

use cc;

fn main() {
    cc::Build::new()
        .cuda(true)
        .cpp(true)
        .flag("-cudart=shared")
        .flag("-ccbin=gcc")
        .files(&["./src/mandelbrot.cpp", "./src/mandelbrot_gpu.cu"])
        .compile("mandelbrot.a");
}

Убедитесь, что вы имеете установленный компилятор CUDA. Выполним cargo build для сборки всего проекта:

$ cargo build
    Compiling mandelbrot_set v0.1.0 (/home/danil/home/danil/RustroverProjects/mandelbrot_set)
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
warning: mandelbrot_set@0.1.0: nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.87s

Если вы хотите использовать другой компилятор или получаете ошибки, замените gcc в build.rs на другое значение. Например:

...
        .flag("-ccbin=clang")
...

Определим функцию calculate_mandelbrot в main.rs:

...
use libc::{c_double, c_int, c_uint};

extern "C" {
    fn calculate_mandelbrot(
        cx: *mut c_double,
        cy: *mut c_double,
        num_points: c_int,
        max_iters: c_uint,
        output: *mut c_uint,
    );
}
...

Изменим generate_set для работы с C++:

fn generate_set(
    ...
) {
    ...
    let mut h_cx = vec![];
    let mut h_cy = vec![];
    let mut output = vec![0; (resolution * resolution) as usize];

    for x in 0..resolution {
        for y in 0..resolution {
            ...
            h_cx.push(cx);
            h_cy.push(cy);
        }
    }

    unsafe {
        calculate_mandelbrot(
            h_cx.as_mut_ptr(),
            h_cy.as_mut_ptr(),
            (resolution * resolution) as c_int,
            max_iters,
            output.as_mut_ptr(),
        );
    }

    for (x, row) in output.chunks(resolution as usize).enumerate() {
        for (y, column) in row.iter().enumerate() {
            let pixel = buffer.get_pixel_mut(x as u32, y as u32);
            let color = gradient.get(*column as usize).unwrap_or(&[0, 0, 0]);
            *pixel = Rgb(*color);
        }
    }

    buffer.save(&file_name).unwrap();
}
Полный код main.rs
use image::{Rgb, RgbImage};
use num::complex::Complex;
use libc::{c_double, c_int, c_uint};

extern "C" {
    fn calculate_mandelbrot(
        cx: *mut c_double,
        cy: *mut c_double,
        num_points: c_int,
        max_iters: c_uint,
        output: *mut c_uint,
    );
}

fn hex2rgb(hex: &str) -> Result<Vec<u8>, String> {
    let hex = hex.trim_start_matches('#');
    if hex.len() != 6 {
        return Err("Invalid HEX color length".to_string());
    }

    let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid HEX color")?;
    let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid HEX color")?;
    let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid HEX color")?;

    Ok(vec![r, g, b])
}

fn lerp_color(color1: &[u8; 3], color2: &[u8; 3], value: f64) -> [u8; 3] {
    [
        (color1[0] as f64 + (color2[0] as f64 - color1[0] as f64) * value) as u8,
        (color1[1] as f64 + (color2[1] as f64 - color1[1] as f64) * value) as u8,
        (color1[2] as f64 + (color2[2] as f64 - color1[2] as f64) * value) as u8,
    ]
}

fn get_gradient(gradient_colors: Vec<&str>, max_iters: u32) -> Vec<[u8; 3]> {
    let mut colors = vec![];
    let mut gradient_colors_rgb = vec![];
    for color in &gradient_colors {
        let rgb = hex2rgb(color).unwrap();
        gradient_colors_rgb.push([rgb[0], rgb[1], rgb[2]]);
    }

    for i in 0..max_iters {
        let color_index = (i as usize * (gradient_colors.len() - 1)) / max_iters as usize;
        let color_value = (i as f64 * (gradient_colors.len() as f64 - 1.0)) / max_iters as f64;
        let value = color_value % 1.0;
        colors.push(lerp_color(
            &gradient_colors_rgb[color_index],
            &gradient_colors_rgb[color_index + 1],
            value,
        ));
    }

    colors
}

fn generate_set(
    file_name: String,
    max_iters: u32,
    colors: Vec<&str>,
    x_min: f64,
    y_min: f64,
    x_max: f64,
    y_max: f64,
    resolution: u32,
) {
    let mut buffer = RgbImage::new(resolution, resolution);
    let gradient = get_gradient(colors, max_iters);
    let mut h_cx = vec![];
    let mut h_cy = vec![];
    let mut output = vec![0; (resolution * resolution) as usize];

    for x in 0..resolution {
        for y in 0..resolution {
            let x_percent = x as f64 / resolution as f64;
            let y_percent = y as f64 / resolution as f64;
            let cx = x_min + (x_max - x_min) * x_percent;
            let cy = y_min + (y_max - y_min) * y_percent;
            h_cx.push(cx);
            h_cy.push(cy);
        }
    }

    unsafe {
        calculate_mandelbrot(
            h_cx.as_mut_ptr(),
            h_cy.as_mut_ptr(),
            (resolution * resolution) as c_int,
            max_iters,
            output.as_mut_ptr(),
        );
    }

    for (x, row) in output.chunks(resolution as usize).enumerate() {
        for (y, column) in row.iter().enumerate() {
            let pixel = buffer.get_pixel_mut(x as u32, y as u32);
            let color = gradient.get(*column as usize).unwrap_or(&[0, 0, 0]);
            *pixel = Rgb(*color);
        }
    }

    buffer.save(&file_name).unwrap();
}

fn num_iters(cx: f64, cy: f64, max_iters: u32) -> u32 {
    let mut z = Complex::new(0.0, 0.0);
    let c = Complex::new(cx, cy);

    for i in 0..=max_iters {
        if z.norm() > 2.0 {
            return i;
        }
        z = z * z + c;
    }

    max_iters
}

fn main() {
    let resolution = 1024;
    let max_iters = 100;

    generate_set(
        "fractal.png".to_string(),
        max_iters,
        vec!["#1C448E", "#6F8695", "#CEC288", "#FFE381", "#DBFE87"],
        -1.5,
        -1.0,
        0.5,
        1.0,
        resolution,
    );
}

Масштабирование

Добавим переменную scale, которая будет отвечать за приближения к определённой точке фрактала:

fn main() {
    let resolution = 1024;
    let target_x = -0.6582034218739634;
    let target_y = 0.44967917993930356;
    let max_iters = 5000;
    let scale = 500_000.0;

    let x_min = target_x - (1.0 / scale);
    let x_max = target_x + (1.0 / scale);
    let y_min = target_y - (1.0 / scale);
    let y_max = target_y + (1.0 / scale);

    generate_set(
        "fractal.png".to_string(),
        ...
        x_min,
        y_min,
        x_max,
        y_max,
        resolution,
    );
}
fractal.png
fractal.png

К сожалению, из-за ограниченной точности float64 качественный масштаб может быть выполнен до 10^{15} раз:

let resolution = 256;
...
let scale = 10f64.powf(15.0);
...
fractal.png
fractal.png

Сглаживание

Как видно из последней визуализации, наше изображение имеет большое количество "случайных точек". Это не является ошибкой, но создаёт неприятный шум.

Решить эту проблему можно путём добавления сэмплирования - это наиболее простой, но прожорливый метод. Его суть заключается в том, что мы n раз случайным образом изменяем координату целевой точки, просчитываем iters, берём среднее значение итераций и красим целевую точку. Вот, как это выглядит.

Для генерации случайных значений, добавим соответствующую библиотеку:

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

[dependencies]
num = "0.4.3"
image = "0.25.1"
libc = "0.2.155"
+rand = "0.9.0-alpha.1"

[build-dependencies]
cc = "1.0.98"

Изменим generate_set:

use rand::Rng;


fn generate_set(
...
  samples: u32,
...
) {
  let mut rng = rand::thread_rng();
  ...
  for _ in 0..samples {
      ...
      let x_percent = (x as f64 + rng.gen::<f64>()) / resolution as f64;
      let y_percent = (y as f64 + rng.gen::<f64>()) / resolution as f64;
      ...
  }
  unsafe {
    calculate_mandelbrot(
      ...
      (resolution * resolution * samples) as c_int,
      ...
    );
  }
  for (x, row) in output.chunks((resolution * samples) as usize).enumerate() {
      for (y, column) in row.chunks(samples as usize).enumerate() {
          let mut sum = 0;
          for iteration in column {
              sum += *iteration as usize;
          }
          let pixel = buffer.get_pixel_mut(x as u32, y as u32);
          let color = gradient.get(sum / column.len()).unwrap_or(&[0, 0, 0]);
          *pixel = Rgb(*color);
      }
  }
}
fn main() {
    ...
    let samples = 16;
    ...
    generate_set(
        ...
        samples
    );
}

Выглядит гораздо лучше!

Финальные штрихи

Воспользуемся экспоненциальным ростом масштаба, чтобы анимировать масштабирование фрактала:

let scale = 10.0_f64.powf((i as f64 / frames as f64) * args.max_scale.ilog10() as f64);

Добавим библиотеку rayon для рендера в многопоточном режиме и clap для поддержки аргументов командной строки. Итоговая версия проекта лежит на GitHub:

$ git clone https://github.com/0x7o/mandelbrot_set
$ cd mandelbrot_set
$ cargo build --release
$ ./target/release/mandelbrot_set \
    --resolution 1024 \
    --colors "#00A3BC, #8B00BD, #81BD00, #BD5400" \
    --x -0.6582034218739634 \
    --y 0.44967917993930356 \
    --iters 3000 \
    --max-scale 1000000000000000 \
    --fps 24 \
    --seconds 60 \
    --n-samples 4 \
    --output ./output
$ ffmpeg -framerate 24 -i output/frame_%09d.png -c:v libx264 -pix_fmt yuv420p -crf 18 -y video.mp4 

Спасибо за прочтение! Я только начинаю изучать Rust, поэтому код далек от совершенства

Комментарии (26)


  1. vasan
    09.06.2024 12:37
    +17

    Самые интересные и красивые фракталы Мандельброта, напоминающие мандалы, получаются при очень громадным зуме, порядка 1.0e³⁰⁰ и более. Но вот проблема, стандартные типы данных (даже таких как long double) не способны обеспечить нужную точность расчётов, из за пресловутого "машинного эпсилона", ограничивая увеличение максимум на 1.0e²³. В этом случае применяют библиотеки, производящие математические операции с числами произвольной точности.

    Может имеет смысл попробовать применить CUDA для расчётов фракталов с громадным зумом?

    Фрактал Мандельброта с зумом 9.0e³⁰⁰
    Фрактал Мандельброта с зумом 9.0e³⁰⁰


    1. 0x7o Автор
      09.06.2024 12:37
      +1

      Пытался найти такие библиотеки, но все безрезультатно


      1. vasan
        09.06.2024 12:37
        +7

        Попробуйте для примера Nanobrot Fractal Renderer. Там в исходном коде есть библиотека (nmblib) для работы с числами произвольной точности, (конкретно смотрите mpreal.h).


    1. davidovsv
      09.06.2024 12:37

      По каким координатам такая красота?


      1. vasan
        09.06.2024 12:37
        +11

        X=0.27533764774673799358866712482462788156671406989542628591627436306743751013023030130967197535665363986058288420463735384997362663584446169657773339617717365950286959762265485804783047336923365261060963100721927003791989610861331863571141065592841226995797739723012374298589823921181693139824190379745910243872940870200527114596661654505 Y=0.006759649405327850670181700456194929502189750234614304846357269137106731032582471677573582008294494705826194131450773107049670717146785957633119244225710271178867840504202402362491296317894835321064971518673775630252745135294700216673815790733343134984120108524001799351076577642283751627469315124883962453013093853471898311683555782404
        Zoom=9e300


    1. DungeonLords
      09.06.2024 12:37

      Не понимаю при чем тут зум? Разве фрактал - это не самоподобная фигура? То, что на фото не похоже на исходный фрактал...


      1. vasan
        09.06.2024 12:37
        +4

        Посмотрите видео, как изменяется фрактал Мандельброта с увеличением зума до 3.9e¹⁴²⁹. И в итоге (конец видео) Вы увидите изначальный фрактал, вот Вам и самоподобие ) https://www.youtube.com/watch?v=thlvdXfl0KA


        1. davidovsv
          09.06.2024 12:37
          +1

          Невероятная красота! Спасибо.


          1. vasan
            09.06.2024 12:37

            Вообще тема с рендерингом фракталов в видео формате очень актуальна. На том же ютуб уйма подобных фрактальных трипов, причём все используют не только разные координаты конечных минибротов, но и различные цветовые карты для раскрашивания фракталов.


    1. Travisw
      09.06.2024 12:37
      +2

      На коврах похожие эскизы


  1. domix32
    09.06.2024 12:37

    Как-то много копипапсты со строками получается. А почему не использовать обычные числа в формате 0xFFFFFF? Ну или хотя бы вспомогательный линейный lerp сделать.

    А rust_gpu прикрутить не пробовали?


    1. 0x7o Автор
      09.06.2024 12:37

      Весь код доступен на Github - https://github.com/0x7o/mandelbrot_set Было удобнее работать с классическим RGB.

      Что касается rust_gpu - это очень ранний проект, который пока не поддерживается стабильными версиями Rust


      1. domix32
        09.06.2024 12:37
        +1

        Ну так классический RGB выглядит точно также и в u8 превращается пачкой простых битовых сдвигов

        fn int_to_vec3(color: u32) -> [u8;3] {
          let r = (color >> 16) as u8;
          let g = ((color & 0xff00) >> 8 ) as u8;
          let b = (color  & 0xff) as u8;
          [r,g,b]
        }
        
        fn main() {
          let [r,g,b] = int_to_vec3(0xFFAABB); // vs "#FFAABB"
          println!("{:X} {:X} {:X}", r, g, b ); // prints "FF AA BB"
        }


        1. 0x7o Автор
          09.06.2024 12:37

          Спасибо, добавлю в код!


  1. vasan
    09.06.2024 12:37

    Такой ещё вопрос к автору: по какой причине Вы использовали именно Rust в смеси с С++, когда можно было данный проект сделать полностью на С++?


    1. 0x7o Автор
      09.06.2024 12:37
      +1

      В целях практики с языком Rust. Его я активно изучаю в последнее время


      1. vasan
        09.06.2024 12:37

        А можно поинтересоваться, что такого имеется в Rust, чего нет в С++?

        P.S. C Rust вообще не знаком.


        1. 0x7o Автор
          09.06.2024 12:37

          Для меня главным фактором в пользу Rust стала безопасность работы с памятью


          1. voldemar_d
            09.06.2024 12:37
            +1

            ИМХО, если в C++ пользоваться только контейнерами и не пользоваться сырыми указателями, тоже проблем с памятью не будет. Ну да, я знаю, что всякое возможно, но вероятность уж точно сильно снижается.


          1. vasan
            09.06.2024 12:37

            Как то привычка сложилась: выделил память - освободи. Хотя умные указатели скорее всего и ввели чтобы забыть об освобождении )


        1. domix32
          09.06.2024 12:37
          +2

          Enum с нагрузкой (aka Tagged Union), минималистичные NewType и tuple, удобные опционалы (Result/Option), нормальные интерфейсы (trait), паттерн матчинг (aka сопоставление с образцом). Относительно простая и единообразная работа с итераторами, рэнжи и срезы из коробки, также как и структурное связывание, да ешё и с валидацией.
          Ну и всякого по мелочи, типа чистых макросов, которые ещё и декоратором можно сделать, f-string-like форматирование, тесты, бенчмарки, доки и куча иного инструментария из коробки, время компиляции чуть меньше чем тепловая смерть вселенной и прочее.

          Фичи которые тоже есть у раст, но многим не заходят: ручное управление временем жизни, границы типов (aka bounds), работа с асинхронными функциями и разделение на красные-синие.

          Автор скорее всего толком не пользовался языком, чтобы что-то про "безопасности памяти" рассказать и как-то столкнуться с ней (учитывая что там сходу unsafe), так что считайте из-за хайпа зашёл.


          1. voldemar_d
            09.06.2024 12:37
            +1

            рэнжи и срезы из коробки, также как и структурное связывание

            ranges и структурное связывание есть в C++. В Rust что-то еще к этому добавили?

            Автор скорее всего толком не пользовался языком, чтобы что-то про "безопасности памяти" рассказать и как-то столкнуться с ней (учитывая что там сходу unsafe), так что считайте из-за хайпа зашёл.

            В данной задаче, ИМХО, кроме как "поизучать раст", нет никакой необходимости именно на нём писать, особенно в свете того, что часть всё равно на C++. Но в целом за статью автору респект однозначно.

            время компиляции чуть меньше чем тепловая смерть вселенной

            На любом языке можно написать такое, что будет компилироваться бесконечно долго. Я не имею ничего против Rust :-) просто без конкретного примера утверждение непонятно о чем.


            1. domix32
              09.06.2024 12:37
              +1

              ranges и структурное связывание есть в C++. В Rust что-то еще к этому добавили?

              21/23 стандарт ещё не раскатали толком, плюс не все алгоритмы (пока?) красиво стыкуются в красивые пайплайны. Да и рэнжи нужно явно конструировать. В Rust они на уровне языка.

              У плюсов отсутвует (пока?) возможность отбросить неиспользуемые биндинги, нельзя биндиться из массивов. Live

              let vec = vec!(1,2,3,4,5,6,7);
              // похоже на spread operator в js
              // связываем содержимое вектора с переменными
              let [a,b,c,..] = vec[..] else { unreachable!()};
              println!("{a} {b} {c}"); // 1 2 3
              vec
                .chunks_exact(3) // режет на чанки без хвостов
                // .chunks(3) // можно ещё так, но тогда паника будет 
                .for_each(|chunk| {    //                 v вот тут
                  let [a,b,c] = chunk[..] else { panic!("Tail is too short") };
                  println!("{a} {b} {c}"); // 1 2 3
                                           // 4 5 6
                                           // panic
                });
                
              // массивы фиксированных размеров можно не spreadить, как вектора
              // std::array вроде так не умеет
              let [r,g,b] = int_to_vec3(0xFF_BB_AA); 
              println!("{r:X} {g:X} {b:X}"); // FF BB AA
              
              // тут практически как в C++
              // только по умолчанию оно будет value, а не ссылка
              let [x, y] = Point{ x: 1, y: 2};

              На любом языке можно написать такое, что будет компилироваться бесконечно долго.

              Одна из двух главных болей в С++ - управление сборкой зависимостей и собственно время компиляции. Если компилировали что-то больше пары файлов на С++ и сталкивались с, например, CMake, то наверняка понимаете насколько в среднем дольше происходит сборка проекта с низкого старта - сидишь ручками прописываешь все кодген юниты, следишь за порядком включения .h файлов, следишь за тем, чтобы зависимости файла собирались раньше собственно зависимого, ради ускорения ещё и меняешь инструментарий с дефолтных компиляторных на всякие ccache/ninja/mold, иногда ещё и пребилд скрипты запускаешь, чтобы склеить маленький файлы в юниты побольше. А там ещё и фиче флаги не забыть в Cmake скормить, а то придётся переконфигурировать пол проекта и собирать их заново. Модули пока ещё в зайчатках, поэтому сжигая киловатт-часы процессорного времени в ожидании пока течёт мой кетчуп компилируется проект, приближаем тепловую смерть вселенной.


              1. voldemar_d
                09.06.2024 12:37
                +1

                21/23 стандарт ещё не раскатали толком

                Структурное связывание появилось в C++17.

                плюс не все алгоритмы (пока?) красиво стыкуются в красивые пайплайны

                Без конкретного примера непонятно, что имеется ввиду. Так-то всегда можно придумать пайплайн, куда какой-нибудь sort "из коробки" не подойдет.

                Да и рэнжи нужно явно конструировать. В Rust они на уровне языка.

                Так и в C++20 тоже на уровне языка. Либо я не понимаю, что такое "не на уровне языка" :-)

                У плюсов отсутвует (пока?) возможность отбросить неиспользуемые биндинги

                Ну просто пишется биндинг, но не используется. В каких-то очередных С++26 неиспользуемое поле можно будет написать символом подчеркивания. ИМХО, это всё какие-то не очень принципиальные мелочи.

                связываем содержимое вектора с переменными

                Кто мешает просто пользоваться ссылками на нужные элементы вектора? Ну да, не в одну строчку будет написано, но и только. Наверное, удобная фича, не спорю, но не сказать, что прямо жить без этого сложно, имхо.

                Если компилировали что-то больше пары файлов на С++ и сталкивались с, например, CMake

                Да, тут много сложностей бывает, согласен.

                Всё это уже много раз на Хабре обсуждалось. Rust хорош тогда, когда не надо привязываться к куче уже написанного старого кода. Либо приходится связки с С++ городить. Тоже рабочий подход, не спорю. Каждый инструмент хорош, когда целесообразен в применении.


                1. domix32
                  09.06.2024 12:37
                  +1

                  Без конкретного примера непонятно, что имеется ввиду.

                  Ближайшее, что вспомнил было вот это. Где-то видел ещё похожий рефакторинг, когда вместо пайплайна algo|algo|algo|algo приходилось делать что-то вроде algo(algo(algo)|algo)|algo . Некрасиво и поток мысли получается нелинейным. Но для некоторых алгоритмов это пока ещё просто не успели добавить, насколько мне известно (аналогично не все алгоритмы имеют constexrp версии). Есть библиотека, которая делает такие пайплайны более приближенные к ржавым.

                  Так и в C++20

                  В С++20 рэнжи есть в стандратной библиотеке, но не на уровне языка. Нельзя сделать for (auto i : 0..20) , нужно явно вызывать адаптер std::iota (не забыв про нюансы, которые пофиксят только в 26 стандарте), нельзя явно сослаться на слайс от контейнера а ля vec[4..10] или как в питоне vec[4:10] , нужно или опять же через iota это делать, либо через std::span . Это уровень стандартной библиотеки, а не уровень языка. Начни писать под какой-нибудь embed без неё, и удобного сахара остаётся не так много. Аналогичная история с std::tuple - в расте достаточно просто явно указать свой кортеж (value1,value2) и либо также дестрктурировать в переменные, либо через var.0/.1/.n обращаться - в отличе от std::get<n>в плюсах.

                  Ну просто пишется биндинг, но не используется

                  Ворнинги так и будут висеть, пока их каким-нибудь (void)_; не заткнёшь. Ну и если у тебя в одном скоупе несколько разных биндингов, то они вполне могут конфликтовать всячески. В 26 пока обещают прочерки только для сопоставления с образцом, насколько мне известно, в остальные места оно не факт что попало.

                  Наверное, удобная фича, не спорю, но не сказать, что прямо жить без этого сложно, имхо.

                  Собственно, речь шла за отличия с плюсами, ни больше ни меньше. В общем жить без этого можно, но когнитивная нагрузка утекает на парсинг всех этих конструкций, которую теоретически можно было бы потратить на что-то чуть более полезное - думы за именование переменных, время жизни, производительность и прочее. cppfront кстати тоже про это.


                  1. vasan
                    09.06.2024 12:37

                    Посмотрел и понял - Rust это сборная солянка из разных языков )

                    рэнжи, оператор as, синтаксис передачи параметров в функции явно из Паскаля.

                    let из Javasript, остальное из С/С++