曼德博集合 Mandelbrot:四个步骤学会算法
🦀 Rust + WASM 实战系列 第 16 篇 阅读时间:约 6 分钟 | 实战可运行
📌 写在前面
前三部分做的所有算法都是**"对每个像素 → 用某种公式算出颜色"**——几何变换是改坐标,卷积是算加权和,亮度是改 RGB 值。
从这一篇开始,进入全新的领域——分形:
对每个像素 → 在复平面迭代 → 根据迭代次数着色
公式超简单:
但它生成的图案是无限的、自相似的、无限复杂的——这就是 Mandelbrot 集合。
这一篇不讲历史,不讲分形几何理论——只讲怎么算。跟着算法一步步走,看一个像素怎么变成颜色。
🚀 TL;DR
整个算法 4 步:
① 像素 → 复数 c (算一个数字 c)
② 公式 z = z² + c (z 反复变)
③ 什么是"逃逸" (z 大到一定程度就停)
④ 颜色怎么定 (按 iter 涂颜色)
核心代码就 7 行:
while iter < 200 && (zr*zr + zi*zi) < 4.0 {
let new_zr = zr*zr - zi*zi + c_re;
let new_zi = 2.0*zr*zi + c_im;
zr = new_zr; zi = new_zi;
iter += 1;
}
600×600 画布 + 200 步 ≈ 7200 万次浮点运算——WASM 比 JS 快 5~10 倍。
📖 目录
一、像素 → 复数
画布和复平面
画布是 个离散的像素网格,每个像素是整数 。
复平面是连续的二维实数平面,每个点是 实数。
我们要给每个像素 对应到一个复数 。
映射公式
| 参数 | 含义 |
|---|---|
| , | 画布中心对应复平面哪个点 |
| 缩放倍率(多少像素 = 1 个复平面单位) |
例子(取 ,):
| 像素位置 | 复数 | ||
|---|---|---|---|
| 画布中心 | |||
| 左上角 | |||
| 右下角 |
默认 center 为什么是 (-0.5, 0)?
曼德博集合整体偏左——主体在 c ∈ [-2, 0.25]。把画布中心定在 (-0.5, 0),集合刚好被框在画布中间。
如果用 center=(0, 0),集合会跑到画布左边,右边一大片空白。
代码
let inv_zoom = 1.0 / zoom;
for py in 0..600 {
for px in 0..600 {
let c_re = center_x + (px as f64 - 300.0) * inv_zoom;
let c_im = center_y + (py as f64 - 300.0) * inv_zoom;
// ... 后面用这个 c ...
}
}
二、公式 z = z² + c 怎么算
z 是复数
复数 ,两部分:
比如 意思是 ,。
复数乘法公式
复数乘法有一个固定公式:
- 实部:
- 虚部:
套用到
设 ,代入上面的公式(,):
再加
代码
let new_zr = zr * zr - zi * zi + c_re; // 实部公式
let new_zi = 2.0 * zr * zi + c_im; // 虚部公式
zr = new_zr;
zi = new_zi;
这两行就是 的代码版——把一行公式拆成"实部"和"虚部"两部分。
用 (纯实数)验证
| 步骤 | |||
|---|---|---|---|
| 0 | |||
| 1 | |||
| 2 |
虚部一直是 0——因为 是纯实数,从 0 出发永远不会有虚部。
三、什么是"逃逸"
概念
迭代过程中,z 越算越大 → "逃逸"(爆炸到无穷)。
我们关心的是"z 在变大的过程中超过多少就算跑飞了"。
为什么阈值是 2?
关键观察:复数乘法会让模长平方。
也就是说, 时,下一步 。
如果某一步 ,那 ,下一步 ,再下一步 ……
指数级爆炸,再也回不来。
所以 一定跑飞——定 2 就够了。
为什么代码里写 < 4.0?
等价于 ,等价于 。
但 比 慢几倍——所以我们省掉 sqrt,直接判断平方和:
四、颜色怎么定
每个像素迭代完后得到一个 iter(0~200 之间)。根据 iter 决定涂什么颜色。
为了直观,我们用 5 色分类(不是渐变,方便表格对照):
| 迭代次数 iter | 颜色 | 含义 |
|---|---|---|
| 1 ~ 4 | 红色 | 跑飞快(1~4 步就逃) |
| 5 ~ 9 | 橙色 | 较快 |
| 10 ~ 29 | 黄色 | 中等 |
| 30 ~ 99 | 绿色 | 较慢 |
| 100 ~ 199 | 蓝色 | 很慢(接近集合边缘) |
| 200 | 黑色 | 200 步都没逃出(在集合内) |
记忆口诀:
iter 越小 → 跑得越快 → 颜色越亮(红橙)
iter 越大 → 跑得越慢 → 颜色越深(蓝)
iter = 200 → 不跑 → 黑色
颜色 vs 集合边界的关系:
远离边界 接近边界 在边界内
iter: 1 100 200
颜色: 红 ──────────→ 蓝 ──────────→ 黑
五、跟着一个像素走一遍算法
选 画布中心 (300, 300),对应 c = -0.5 + 0i:
z = 0 + 0i
逐行迭代 + 颜色判定
| 步骤 | zr | zi | zr² + zi² | 还在算? | 如果停 → 颜色 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | ✓ | - |
| 1 | -0.5 | 0 | 0.25 | ✓ | 1 步 →红 |
| 2 | -0.25 | 0 | 0.0625 | ✓ | 2 步 →红 |
| 3 | -0.4375 | 0 | 0.191 | ✓ | 3 步 →红 |
| 4 | -0.309 | 0 | 0.095 | ✓ | 4 步 →红 |
| 5 | -0.405 | 0 | 0.164 | ✓ | 5 步 →橙 |
| ... | ... | ... | ... | ✓ | 一直在 [-1, 1] 之间晃 |
| 50 | -0.358 | 0 | 0.128 | ✓ | 50 步 →绿 |
| ... | ... | ... | ... | ✓ | ... |
| 199 | -0.367 | 0 | 0.135 | ✓ | 199 步 →蓝 |
| 200 | -0.367 | 0 | 0.135 | ✗ 达到上限 | 黑色(在集合内) |
结论:c = -0.5 永不逃 → 涂黑色。
再选另一个像素 c = 0.5(画布中心偏右):
| 步骤 | zr | zi | zr² + zi² | 还在算? | 如果停 → 颜色 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | ✓ | - |
| 1 | 0.5 | 0 | 0.25 | ✓ | 1 步 →红 |
| 2 | 0.75 | 0 | 0.5625 | ✓ | 2 步 →红 |
| 3 | 1.0625 | 0 | 1.129 | ✓ | 3 步 →红 |
| 4 | 1.629 | 0 | 2.654 | ✓ | 4 步 →红 |
| 5 | 3.154 | 0 | 9.95 > 4 | ✗逃了! | 红色(5 步就逃,最亮的红) |
结论:c = 0.5 5 步就跑飞 → 涂红色。
算法总结
对每个像素 (px, py):
1. 像素 → 复数 $c$ ← 第 ① 节
2. $z = 0$
3. 重复:
- if $\text{zr}^2 + \text{zi}^2 > 4$:停止 ← 第 ③ 节
- $z = z^2 + c$ ← 第 ② 节
4. 用 $\text{iter}$ 涂颜色 ← 第 ④ 节
六、关键代码
#[wasm_bindgen]
pub fn mandelbrot(
width: u32, height: u32,
center_x: f64, center_y: f64, zoom: f64,
max_iter: u32, palette: u32,
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let mut pixels = vec![0u8; w * h * 4];
let inv_zoom = 1.0 / zoom;
for py in 0..h {
for px in 0..w {
// ① 像素 → 复数 c
let c_re = center_x + (px as f64 - w as f64 / 2.0) * inv_zoom;
let c_im = center_y + (py as f64 - h as f64 / 2.0) * inv_zoom;
// ②③ 迭代 + 逃逸判断
let mut zr = 0.0;
let mut zi = 0.0;
let mut iter = 0;
while iter < max_iter && (zr * zr + zi * zi) < 4.0 {
let new_zr = zr * zr - zi * zi + c_re;
let new_zi = 2.0 * zr * zi + c_im;
zr = new_zr;
zi = new_zi;
iter += 1;
}
// ④ 涂色(按 iter 分桶)
let (r, g, b) = color_from_iter(iter, max_iter, palette);
let idx = (py * w + px) * 4;
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = 255;
}
}
pixels
}
核心循环 7 行。
涂色函数(按上面 ④ 节的分桶)
fn color_from_iter(iter: u32, max_iter: u32, palette: u32) -> (u8, u8, u8) {
if iter >= max_iter {
return (0, 0, 0); // 黑:200 步都没逃
}
let t = iter as f64 / max_iter as f64; // 0~1
match palette {
0 => fire(t), // 经典红橙黄
1 => ocean(t), // 蓝色系
2 => forest(t), // 绿色系
_ => grayscale(t), // 灰度
}
}
fn fire(t: f64) -> (u8, u8, u8) {
let r = (255.0 * (1.0 - (1.0 - t).powi(2))) as u8;
let g = (255.0 * t.powi(2)) as u8;
let b = (255.0 * (t * 4.0).min(1.0) * t.powi(3) * 4.0) as u8;
(r, g, b)
}
七、前端效果展示
八、必须用 WASM
1. 性能坑:JS 跑不动,必须用 WASM
总工作量:
每个像素平均迭代 次,总浮点运算 万次。
实测:
| 实现 | 耗时 | 体感 |
|---|---|---|
| 纯 JS | 800~1500 ms | 卡顿 |
| WASM (Rust) | 100~200 ms | 流畅 |
加速 5~10 倍。Mandelbrot 是 CPU 密集型算法,JS 原生跑不动——必须用 WASM。
一句话总结
Mandelbrot 算法五步:
① 像素 → 复数 c ② 公式 z = z² + c(代码拆成实部/虚部两行) ③ 逃逸判断:|z|² > 4(即 |z| > 2) ④ 颜色:iter 小 → 红,iter 大 → 蓝,iter=200 → 黑 ⑤ 跟着 c = -0.5(涂黑)和 c = 0.5(涂红)走一遍
整个代码核心 7 行,WASM 比 JS 快 5~10 倍。
📦 项目地址:pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签:#Rust #WebAssembly #分形 #Mandelbrot #复数 #迭代 #算法