用傅里叶画字母
用傅里叶画字母
我花了几天时间做了一个交互 demo:用一组旋转的圆(本轮,Epicycles)画出名字里的字母。这篇文章记录数学原理、踩过的坑和解决方案。
傅里叶本轮是什么?
对一组二维采样点做离散傅里叶变换(DFT),得到一组旋转向量。把它们首尾相连,每个向量以自己的频率和幅度旋转,最末端的轨迹就能描出任意封闭曲线。
给定 N 个复数采样点 :
每个 编码了一个本轮的幅度、相位和频率。渲染时, 时刻的笔位置是:
在 canvas 里:每一帧把所有旋转向量加起来,画出向量链,记录轨迹。
采样字母路径
第一个问题:怎么得到一组有序的 2D 点来描出字母轮廓?
第一次尝试:把字母渲染到离屏 canvas,读像素,用 Moore 邻域轮廓追踪排序边缘像素。理论可行——但对细笔画和尖角,追踪器会在 1 像素宽的笔尖处进入 2 步死循环,永远不终止。
第二次尝试:极角排序——收集所有边缘像素,按与质心的角度排序。听起来很优雅,但对非凸形状立刻崩溃。字母 Y 有三条臂,极角扫描会把每条臂看成两个区间,画出星形而不是 Y。
真正可行的方案:SVG path.getPointAtLength()。
每个字母都可以用 SVG 路径或折点定义,然后:
const N = 512
const total = path.getTotalLength()
const pts = Array.from({ length: N }, (_, i) => {
const p = path.getPointAtLength(i / N * total)
return { x: p.x, y: p.y }
})等间距采样,顺序天然正确,不需要追踪、排序或处理拓扑。DFT 直接正常工作。
U 字母的问题
字母 U 顶部是开口的。但 DFT 需要封闭路径—— 从 循环回 ,起点和终点必须相同。
最朴素的封闭方式:连接两个顶角,画出一条横线。结果是方括号,不是 U。
方案一:从左上角出发,原路返回。
路径:(左上)→ 左边向下 → 曲线 → 右边向上 → (右上)→ 原路返回 → (左上)
闭合是零长度,没有横盖。但动画从左上角开始,第一笔就是往下画。每个循环结束时笔在左上角,下一帧又立刻往下——在每次循环的起始处出现一条明显的从顶角到底部的竖线。
方案二:从底部中心出发。
路径:
(底部中心)→ 向上描左臂 →(左上)→ 原路返回 →(底部中心)(底部中心)→ 向上描右臂 →(右上)→ 原路返回 →(底部中心)
起点 = 终点 = 曲线最深处。循环"缝合处"藏在曲线中间,视觉上完全不可见。每条臂独立地去-返。
U: [
{x:.5, y:1},
{x:.35, y:.97}, {x:.2, y:.87}, {x:.15, y:.68}, {x:.15, y:0}, // 左臂向上
{x:.15, y:.68}, {x:.2, y:.87}, {x:.35, y:.97}, // 原路返回
{x:.5, y:1},
{x:.65, y:.97}, {x:.8, y:.87}, {x:.85, y:.68}, {x:.85, y:0}, // 右臂向上
{x:.85, y:.68}, {x:.8, y:.87}, {x:.65, y:.97}, // 原路返回
{x:.5, y:1}, // 零闭合
]干净的 U,没有任何多余笔画。
Math.min 的坑
在对数千个点做归一化时:
const minX = Math.min(...points.map(p => p.x)) // 💥 RangeErrorJavaScript 把展开的数组变成函数参数。超过几千个元素后,调用栈溢出。用 reduce:
const minX = points.reduce((m, p) => Math.min(m, p.x), Infinity)谐波数量选择
用多少个本轮?太少了字母的棱角会丢失,太多了每个采样噪声都被忠实还原。
对 512 个采样点的字母路径,80–100 个谐波正好——棱角清晰,不放大噪声。按幅度排序渲染(大圆先,小圆后)也让视觉更清爽:先看到整体结构,再是细节。
在线 Demo
点这里看 Demo——五个字母同时动画,各自不同颜色。
约 200 行原生 JS,不依赖任何库,纯 canvas。DFT 就是一个对采样点的双重循环。
下一篇:用 DLA(扩散限制聚集)晶体生长算法,以同样的字母为晶核,让粒子随机游走直到碰触晶体——另一种涌现。
