Published on

分形及其可视化

Authors

概述

分形在数学上的定义是一种可以从任意小尺度层面细分的几何形状结构,通常有一个超过拓扑维度的分形维数。很多分形在不同的尺度上看起来很相似,譬如曼德布洛特集(Mandelbrot Set)它的很多局部形状在放大之后看起来和整体特征具备高度相似性。在更加严格的定义下,分形是“豪斯多夫维数(Hausdorff dimension)严格超过拓扑维数的的集合”。豪斯多夫维数是度量粗糙度的,单个点是 0,线段是 1,正方形是 2,立方体是 3。对于定义平滑形状或具备少量角的形状(传统几何学形状)的点集合,豪斯多夫维数是与拓扑维度一致的整数。而分形一般具有非整数的豪斯多夫维数,举个例子:

Koch Snowflake

Koch 雪花由等边三角形构成,每次迭代组成的线段都被分为 3 个单位长度线段,新创建的中间线段被用作指向外侧等边三角形的底边,然后删除该底线以留下单位长度为 4 的最终结果

这是 Koch 曲线(雪花曲线)头四次的迭代。豪斯多夫维数的计算是由两个因素决定的,一个是比例因子(S = 3),一个是自相似对象数量(N = 4),第一次迭代后维数 D 的定义是:

D = (log N) / (log S) = (log 4) / (log 3) \approx 1.26

当然,这是较为简单的早期定义,当前数学家们的共识是,理论分形是无限自相似的迭代和详细的数学结构。它不仅限于几何图案,也可以描述时间过程等。

Fractal 这个词是由数学家 Mandelbrot 在 1975 年创造的,源自拉丁语,原始词根意思是“破碎的”、“断裂的”。Mandelbrot 随后也将分形维数的理论扩展到了自然界的几何图形中。

总结起来,分形有三个显著的特征:自相似性、分形维数大于其拓扑维数、数学方程式的分形“无处可微”。自相似性和分形维数以及拓扑维数的特征已经提过,无处可微指的是,譬如 Koch 曲线,我们无法找到足够小的直线段去贴合其曲线,Koch 曲线的周长是无限的。

分形可视化

Koch Snowflake

Koch Snowflake 是主观定义的分形迭代,具体绘制时,只要把线段顶点输入,即可把当前线段切分为四个分割变形后的线段部分,对每一个新的线段,又可以继续递归迭代这一过程。虽然听起来简单,但它的计算却是最复杂的,当深度超过较小值之后,计算量会随着递归深度激增。

<canvas id="canvas" width="800" height="600"></canvas>

<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

// 设置画布中心点和基本参数
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const size = 300; // 雪花大小

// 计算等边三角形的三个顶点
function getTrianglePoints(centerX, centerY, size) {
    const height = size * Math.sqrt(3) / 2;
    return [
        {x: centerX, y: centerY - height * 2/3},  // 上顶点
        {x: centerX - size/2, y: centerY + height/3},  // 左下顶点
        {x: centerX + size/2, y: centerY + height/3}   // 右下顶点
    ];
}

// Koch 曲线生成函数(修正版)
function kochCurve(start, end, depth) {
    if (depth === 0) {
        ctx.lineTo(end.x, end.y);
        return;
    }
    
    // 计算分段点
    const dx = end.x - start.x;
    const dy = end.y - start.y;
    
    // 三等分点
    const oneThird = {
        x: start.x + dx / 3,
        y: start.y + dy / 3
    };
    const twoThird = {
        x: start.x + dx * 2/3,
        y: start.y + dy * 2/3
    };
    
    // 计算凸起点(向外的等边三角形顶点)
    const angle = Math.atan2(dy, dx) + Math.PI/3; // 修改这里:加号替换减号
    const length = Math.sqrt(dx*dx + dy*dy) / 3;
    const peak = {
        x: oneThird.x + Math.cos(angle) * length,
        y: oneThird.y + Math.sin(angle) * length
    };
    
    // 递归绘制四个部分
    kochCurve(start, oneThird, depth-1);
    kochCurve(oneThird, peak, depth-1);
    kochCurve(peak, twoThird, depth-1);
    kochCurve(twoThird, end, depth-1);
}

// 绘制 Koch 雪花(带动画)
function drawKochSnowflake(depth, currentDepth = 0) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    const points = getTrianglePoints(centerX, centerY, size);
    
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    
    // 绘制三条边
    kochCurve(points[0], points[1], currentDepth);
    kochCurve(points[1], points[2], currentDepth);
    kochCurve(points[2], points[0], currentDepth);
    
    ctx.closePath();
    ctx.strokeStyle = '#000';
    ctx.stroke();

    // 动画效果
    if (currentDepth < depth) {
        setTimeout(() => {
            drawKochSnowflake(depth, currentDepth + 1);
        }, 1000);
    }
}

// 添加控制按钮
const controls = document.createElement('div');
controls.style.position = 'fixed';
controls.style.top = '20px';
controls.style.left = '20px';

const depthSelect = document.createElement('select');
for (let i = 0; i <= 6; i++) {
    const option = document.createElement('option');
    option.value = i;
    option.text = `深度: ${i}`;
    depthSelect.appendChild(option);
}
depthSelect.value = '4';
depthSelect.onchange = () => drawKochSnowflake(parseInt(depthSelect.value));

const redrawButton = document.createElement('button');
redrawButton.textContent = '重新绘制';
redrawButton.onclick = () => drawKochSnowflake(parseInt(depthSelect.value));

controls.appendChild(depthSelect);
controls.appendChild(redrawButton);
document.body.appendChild(controls);

// 初始绘制
drawKochSnowflake(4);
</script>

Mandelbrot Set

Mandelbrot 集合,其定义由复平面中的递归关系映射而来:

zn+1=zn2+cz_{n+1} = z_n^2 + c
z0=0z_0 = 0

z 初始为 0,c 是某个虚数。c 点的空间映射到画布坐标的逻辑是这样的:

  • 复平面取值(-2 到 2)映射到画布当前宽高(c 的实部、虚部映射到 x / y)
  • 不逃逸点为黑色(|z| \leq 2),这些就是所谓 Mandelbrot 集合点
  • 其他点根据逃逸速度(z 绝对值增长速度)分别着色

具体绘制方法,就是 c 实部、虚部从 -2 到 2 分别取值,映射到对照的画布点。每次偏移量可以根据一个像素进行计算。设定一个迭代次数之后,根据最终 z 值绝对值的区间,映射到不同颜色即可。为方便着色,使用 HSV(或 HSL 等)可以用单一饱和度迭代颜色的色彩空间来映射。

<canvas id="canvas" width="800" height="600"></canvas>

<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);

// 基本参数
const MAX_ITERATIONS = 100;  // 最大迭代次数
const ESCAPE_RADIUS = 2;     // 逃逸半径
let zoom = 1;               // 缩放级别
let offsetX = -0.5;        // 中心点X偏移
let offsetY = 0;           // 中心点Y偏移

// 将画布坐标映射到复平面
function mapToComplex(x, y) {
    const real = (x - canvas.width/2) * 4/canvas.width * (1/zoom) + offsetX;
    const imag = (y - canvas.height/2) * 4/canvas.height * (1/zoom) + offsetY;
    return { real, imag };
}

// 计算一个点是否属于 Mandelbrot 集合
function computePoint(cr, ci) {
    let zr = 0;
    let zi = 0;
    let i;

    for (i = 0; i < MAX_ITERATIONS; i++) {
        // 计算 z = z^2 + c
        const zr_new = zr * zr - zi * zi + cr;
        const zi_new = 2 * zr * zi + ci;
        
        zr = zr_new;
        zi = zi_new;

        // 检查是否超过逃逸半径
        if (zr * zr + zi * zi > ESCAPE_RADIUS * ESCAPE_RADIUS) {
            break;
        }
    }

    return i;
}

// 颜色映射函数
function getColor(iterations) {
    if (iterations === MAX_ITERATIONS) {
        return [0, 0, 0]; // 黑色表示在集合内
    }
    
    // 平滑着色
    const hue = (iterations % 16) / 16;
    const saturation = 1;
    const value = iterations < MAX_ITERATIONS ? 1 : 0;
    
    // HSV to RGB 转换
    const h = hue * 6;
    const f = h - Math.floor(h);
    const p = value * (1 - saturation);
    const q = value * (1 - f * saturation);
    const t = value * (1 - (1 - f) * saturation);

    let r, g, b;
    switch (Math.floor(h)) {
        case 0: r = value; g = t; b = p; break;
        case 1: r = q; g = value; b = p; break;
        case 2: r = p; g = value; b = t; break;
        case 3: r = p; g = q; b = value; break;
        case 4: r = t; g = p; b = value; break;
        default: r = value; g = p; b = q;
    }

    return [r * 255, g * 255, b * 255];
}

// 绘制 Mandelbrot 集合
function drawMandelbrot() {
    const data = imageData.data;
    
    for (let y = 0; y < canvas.height; y++) {
        for (let x = 0; x < canvas.width; x++) {
            const c = mapToComplex(x, y);
            const iterations = computePoint(c.real, c.imag);
            const [r, g, b] = getColor(iterations);
            
            const idx = (y * canvas.width + x) * 4;
            data[idx] = r;     // Red
            data[idx+1] = g;   // Green
            data[idx+2] = b;   // Blue
            data[idx+3] = 255; // Alpha
        }
    }
    
    ctx.putImageData(imageData, 0, 0);
}

// 添加交互控制
function addControls() {
    canvas.addEventListener('wheel', (e) => {
        e.preventDefault();
        const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
        zoom *= zoomFactor;
        
        // 调整偏移以保持鼠标位置不变
        const mouseX = e.offsetX;
        const mouseY = e.offsetY;
        const before = mapToComplex(mouseX, mouseY);
        zoom *= zoomFactor;
        const after = mapToComplex(mouseX, mouseY);
        offsetX += before.real - after.real;
        offsetY += before.imag - after.imag;
        
        drawMandelbrot();
    });

    let isDragging = false;
    let lastX, lastY;

    canvas.addEventListener('mousedown', (e) => {
        isDragging = true;
        lastX = e.offsetX;
        lastY = e.offsetY;
    });

    canvas.addEventListener('mousemove', (e) => {
        if (!isDragging) return;
        
        const dx = e.offsetX - lastX;
        const dy = e.offsetY - lastY;
        
        offsetX -= dx * 4/canvas.width * (1/zoom);
        offsetY -= dy * 4/canvas.height * (1/zoom);
        
        lastX = e.offsetX;
        lastY = e.offsetY;
        
        drawMandelbrot();
    });

    canvas.addEventListener('mouseup', () => {
        isDragging = false;
    });
}

// 初始化
addControls();
drawMandelbrot();
</script>

Julia Set

Julia Set 对应一个固定的 c 下,某个初始复数 z0z_0 不逃逸的集合。

zn+1=zn2+cz_{n+1} = z_n² + c

不同的复数 c 对应的 Julia 集都不一样。

使用固定 C 值,绘制 Julia 集

<canvas id="canvas" width="800" height="600"></canvas>
<div id="controls" style="position: fixed; top: 20px; left: 20px;"></div>

<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);

// 基本参数
const MAX_ITERATIONS = 100;
const ESCAPE_RADIUS = 2;
let zoom = 1;
let offsetX = 0;
let offsetY = 0;
let juliaC = { real: -0.4, imag: 0.6 }; // Julia 集的 c 值

// 将画布坐标映射到复平面
function mapToComplex(x, y) {
    const real = (x - canvas.width/2) * 4/canvas.width * (1/zoom) + offsetX;
    const imag = (y - canvas.height/2) * 4/canvas.height * (1/zoom) + offsetY;
    return { real, imag };
}

// 计算复数乘法
function complexMultiply(a, b) {
    return {
        real: a.real * b.real - a.imag * b.imag,
        imag: a.real * b.imag + a.imag * b.real
    };
}

// 计算复数加法
function complexAdd(a, b) {
    return {
        real: a.real + b.real,
        imag: a.imag + b.imag
    };
}

// 计算 Julia 集合的一个点
function computeJulia(zr, zi) {
    let z = { real: zr, imag: zi };
    let i;

    for (i = 0; i < MAX_ITERATIONS; i++) {
        // z = z^2 + c
        z = complexAdd(complexMultiply(z, z), juliaC);
        
        // 检查是否逃逸
        if (z.real * z.real + z.imag * z.imag > ESCAPE_RADIUS * ESCAPE_RADIUS) {
            break;
        }
    }

    return i;
}

// HSL 到 RGB 的转换
function hslToRgb(h, s, l) {
    let r, g, b;

    if (s === 0) {
        r = g = b = l;
    } else {
        const hue2rgb = (p, q, t) => {
            if (t < 0) t += 1;
            if (t > 1) t -= 1;
            if (t < 1/6) return p + (q - p) * 6 * t;
            if (t < 1/2) return q;
            if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
            return p;
        };

        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        const p = 2 * l - q;
        r = hue2rgb(p, q, h + 1/3);
        g = hue2rgb(p, q, h);
        b = hue2rgb(p, q, h - 1/3);
    }

    return [
        Math.round(r * 255),
        Math.round(g * 255),
        Math.round(b * 255)
    ];
}

// 获取颜色
function getColor(iterations) {
    if (iterations === MAX_ITERATIONS) {
        return [0, 0, 0];
    }
    
    // 使用 HSL 颜色空间创建平滑的颜色渐变
    const hue = (iterations % 40) / 40;
    const saturation = 0.8;
    const lightness = iterations < MAX_ITERATIONS ? 0.5 : 0;
    
    return hslToRgb(hue, saturation, lightness);
}

// 绘制 Julia 集
function drawJuliaSet() {
    const data = imageData.data;
    
    for (let y = 0; y < canvas.height; y++) {
        for (let x = 0; x < canvas.width; x++) {
            const point = mapToComplex(x, y);
            const iterations = computeJulia(point.real, point.imag);
            const [r, g, b] = getColor(iterations);
            
            const idx = (y * canvas.width + x) * 4;
            data[idx] = r;
            data[idx+1] = g;
            data[idx+2] = b;
            data[idx+3] = 255;
        }
    }
    
    ctx.putImageData(imageData, 0, 0);
}

// 添加交互控制
function addControls() {
    const controls = document.getElementById('controls');
    
    // c 值控制滑块
    const createSlider = (label, min, max, value, property) => {
        const div = document.createElement('div');
        div.innerHTML = `${label}: <input type="range" min="${min}" max="${max}" 
                        step="0.01" value="${value}" style="width: 200px;">
                        <span>${value}</span>`;
        
        const slider = div.querySelector('input');
        const valueDisplay = div.querySelector('span');
        
        slider.addEventListener('input', (e) => {
            const value = parseFloat(e.target.value);
            juliaC[property] = value;
            valueDisplay.textContent = value;
            drawJuliaSet();
        });
        
        controls.appendChild(div);
    };

    createSlider('Real Part', -2, 2, juliaC.real, 'real');
    createSlider('Imaginary Part', -2, 2, juliaC.imag, 'imag');

    // 缩放和平移控制
    canvas.addEventListener('wheel', (e) => {
        e.preventDefault();
        const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
        zoom *= zoomFactor;
        drawJuliaSet();
    });

    let isDragging = false;
    let lastX, lastY;

    canvas.addEventListener('mousedown', (e) => {
        isDragging = true;
        lastX = e.offsetX;
        lastY = e.offsetY;
    });

    canvas.addEventListener('mousemove', (e) => {
        if (!isDragging) return;
        
        const dx = e.offsetX - lastX;
        const dy = e.offsetY - lastY;
        
        offsetX -= dx * 4/canvas.width * (1/zoom);
        offsetY -= dy * 4/canvas.height * (1/zoom);
        
        lastX = e.offsetX;
        lastY = e.offsetY;
        
        drawJuliaSet();
    });

    canvas.addEventListener('mouseup', () => {
        isDragging = false;
    });

    // 预设 c 值按钮
    const presets = [
        { name: 'Classic', real: -0.4, imag: 0.6 },
        { name: 'Dragon', real: -0.8, imag: 0.156 },
        { name: 'Spiral', real: 0.285, imag: 0.01 },
        { name: 'Dendrite', real: -0.4, imag: -0.59 }
    ];

    const presetDiv = document.createElement('div');
    presetDiv.style.marginTop = '10px';
    presets.forEach(preset => {
        const button = document.createElement('button');
        button.textContent = preset.name;
        button.onclick = () => {
            juliaC.real = preset.real;
            juliaC.imag = preset.imag;
            document.querySelectorAll('input[type="range"]')[0].value = preset.real;
            document.querySelectorAll('input[type="range"]')[1].value = preset.imag;
            document.querySelectorAll('span')[0].textContent = preset.real;
            document.querySelectorAll('span')[1].textContent = preset.imag;
            drawJuliaSet();
        };
        presetDiv.appendChild(button);
    });
    controls.appendChild(presetDiv);
}

// 初始化
addControls();
drawJuliaSet();
</script>

Julia 集和 Mandelbrot 集的联系

Fractal Frames

使用分形帧绘制炫光图

参考资料