你肯定刷到过那个画面:一个接一个的圆套着圆,圆心之间互相牵着走,最外圈的小笔尖拖出一条不断延伸的波形,最后硬生生拼出一个棱角分明的方波。这几乎是“傅里叶级数”的官方代言图,我第一次看到时第一反应是“这玩意儿到底是怎么算出来,又是怎么画出来的”。后来我用 MATLAB 画过,用 Python 重写过,也帮别人用 PPT 手工仿过,前前后后踩了不少坑。这篇文章就是完整讲清楚“旋转圆叠加方波”动画怎么做出来:从方波的傅里叶级数原理,到 Python 代码逐行讲解,再到 Manim、Canvas、PPT 这些不同路线的取舍,以及我实际操作中遇到的各类翻车现场和排查方法。无论你是正在学傅里叶变换的学生,还是想给自己的视频或课件做一个数学可视化动画,这篇文章都值得直接收藏照着做。
1. 先搞清楚:方波为什么能被旋转圆画出来
1.1 一条方波等于一堆正弦波相加
很多人第一次看到旋转圆动画时,觉得圆和方波完全是两个世界的东西。其实关键在于傅里叶级数:任何满足条件的周期函数,都能拆成一系列不同频率、不同振幅的正弦波和余弦波。方波是最经典的例子,它的分解公式长这样:
f(t) = (4/π) × [sin(t) + (1/3)sin(3t) + (1/5)sin(5t) + (1/7)sin(7t) + ...]
注意几个细节:第一,系数是 4/π;第二,括号里只出现奇数频率,1、3、5、7、9...,所有偶数次谐波都是零;第三,每个频率 n 的振幅是 1/n。这些细节直接决定你动画里每个圆的半径和转速,所以务必记牢。
为什么只有奇数次谐波?因为标准方波在上下半个周期里是对称的奇函数,偶数次谐波的正弦项会在拼起来时互相抵消。我当时第一次自己推这个公式,算到第三项就发现规律了,后面全是 4/(πn) 乘 sin(nt) 的样子。你如果只是想复现动画,不用把积分推导背下来,但至少要知道“圆的数量越多,逼近方波越精确”。
1.2 正弦波和旋转圆其实是一回事
接下来是动画的核心桥梁:为什么正弦波可以画成一个旋转的圆?这要归功于欧拉公式:
e^(iθ) = cos(θ) + i·sin(θ)
也就是说,如果一个点在复平面里做半径为 r 的匀速圆周运动,它的位置写成:
z(t) = r·e^(i ω t) = r·cos(ωt) + i·(r·sin(ωt))
其中实部是水平方向的投影,虚部是竖直方向的投影。你看,这个点的竖直坐标正好就是 r·sin(ωt),一条正弦波。换句话说,一个旋转圆的竖直投影,就是一条正弦曲线。所以每一级傅里叶级数里的 sin(nt) 项,我都可以“翻译”成一个小圆,圆半径就是这一项的振幅 4/(πn),圆转速就是频率 n。
把多个旋转圆嵌套起来时,大圆上带着小圆,小圆上再带着更小的圆,最后的末端点位置就是所有圆的向量叠加。末端点的竖直坐标,恰好就是所有正弦项相加后的值。你保留的圆越多,这个竖直坐标随时间的轨迹就越接近方波。这也是“旋转圆叠出方波”动画的全部秘密——它画的根本不是圆,而是“圆周运动的竖直投影之和”。
1.3 别误解:圆链末端画出的不是整条方波
这里有一个特别容易混淆的细节,我在不少讨论贴里见过。如果你把所有圆看成复平面上的向量求和,那么末端点 P(t) = Σ r_n e^(i n t),它在复平面里拖出的轨迹,并不就是方波的轮廓。准确说法是:末端点的虚部(竖直坐标)随时间变化才是方波。
所以动画通常有两种呈现形式:一种是在圆链旁边还有一个“画波形”的区域,时间轴往右展开,末端点的竖直位置被同步投射到这个二维坐标里,画出一条方波曲线。另一种是把末端点在复平面上的轨迹直接画出来,那得到的其实是一个近似的什么图形呢?取决于你用的是纯奇次正频率项还是加了负频率项。如果用的是前一种单侧圆链,复平面轨迹会比较像“带圆弧的方波变形图”,而不是教科书里那种横着的方波波形式。
我建议刚上手时先用“圆链 + 右侧波形展开”这种布局,因为它的视觉解释最清晰,观众一眼就能看到圆在转、波在长。等理解了这一步,再去玩复平面轨迹画矩形轮廓也不迟。
2. 制作路线选型:别一上来就上大引擎
2.1 四条常用路线对比
把原理搞明白之后,就到了“怎么做出来”的问题。这个动画的实现路径非常多,我帮你梳理一下当前最主流的四条路线,以及它们的适用场景:
| 路线 | 上手难度 | 视觉效果 | 适合场景 |
|---|---|---|---|
| Python + Matplotlib | 低 | 中等,偏学术 | 快速验证原理、教学演示、导出 GIF/MP4 |
| Python + Manim | 中高 | 极高,丝滑运镜 | 高质量数学动画视频,接近 3Blue1Brown 风格 |
| JavaScript + Canvas | 中 | 高,可交互 | 网页展示、博客动态 banner、H5 交互动画 |
| PPT / GeoGebra 手工模拟 | 低 | 较低,适合粗略示意 | 一次性的课堂演示,不追求精细 |
这里提醒一句,网络热词里经常出现 animation.css、PPT 动画、逐帧动画之类的关键词,但很多预设动画库对傅里叶圆链其实帮不上忙,因为这个动画的核心是“每一帧都要重新计算向量叠加位置”,而不是位移循环。用 CSS 做出来的旋转圆最多是个外形像,数学上并不准确。所以如果你追求的是真正的傅里叶级数效果,至少需要一点代码能力,或者是 GeoGebra 这种数学工具。
2.2 为什么我推荐先用 Matplotlib 复刻
首推 Matplotlib,原因很简单:Python 环境大多数人都有,matplotlib 是标配,而且它的 FuncAnimation 模块天生就是做这种“每帧更新图形”的动画。对比 Manim,Matplotlib 的安装和排错成本低得多,不会出现“渲染半天变成黑屏”这种劝退问题;对比 Canvas,Matplotlib 不需要考虑浏览器兼容,也没有必须实时计算导致掉帧的压力。
我之前帮一个朋友做教学视频,对方要求“画面简洁、能看出圆的层级、波形要清晰”,我用 Matplotlib 写了两个版本,一个 3 分钟能跑出结果,一个做了更多美化用了半小时。如果一上来就用 Manim,光是安装依赖和配 LaTeX 字体可能就够折腾一晚上。所以实战建议是:第一次复现用 Matplotlib,跑通以后觉得不够炫再迁移到 Manim,后者很多概念是相通的。
3. 完整实操:用 Matplotlib 从零搭出旋转圆叠加方波
3.1 数学骨架:先把系数和相位算对
做动画前,先把数据模型写对。标准方波单侧级数的第 n 项是:
r_n = 4 / (π n),角度 θ_n = n t
因为我只取奇数次 n,所以 n 可以写成 2k+1,k 从 0 开始。比如想叠加前 10 个谐波,k 取 0 到 9,对应 n = 1, 3, 5, ..., 19。
末端点位置计算公式是:
x_end = Σ r_n cos(n t) y_end = Σ r_n sin(n t)
每个圆的圆心就是前一个向量的末端,第 k 个圆的圆心坐标是前 k 个向量累加结果,第 k 个圆的半径是 r_n。这里最容易出错的是:某个圆自己的旋转角度起点相,标准公式里是 θ = n t,也就是所有圆在 t=0 时角度都是 0,如果为了视觉效果想加初始相位,必须所有圆统一加同一个值,否则叠加出来的波形形状会变。
3.2 可直接运行的 Python 代码
下面这个脚本是我实际用过并精简后的版本,它实现了左侧圆链 + 右侧方波同步生长的效果。代码可以直接保存为 .py 运行,前提是你已经装好 numpy 和 matplotlib。
import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation # ---------- 参数设置 ---------- N_HARMONICS = 15 # 叠加 15 个谐波(实际 n 为 1,3,5,...,29) FPS = 30 # 每秒帧数 DURATION = 12 # 动画总时长(秒) WAVE_SCALE = 6.0 # 右侧波形图的横向拉伸系数 CIRCLE_SCALE = 1.0 # 左侧圆链整体缩放,避免超出画布 # ---------- 计算每个圆的半径和角速度 ---------- n_values = [2 * k + 1 for k in range(N_HARMONICS)] radii = [4.0 / (np.pi * n) for n in n_values] omegas = [n for n in n_values] # ---------- 创建画布 ---------- fig, ax = plt.subplots(figsize=(14, 6)) ax.set_xlim(-1.5, 13.5) ax.set_ylim(-1.8, 1.8) ax.set_aspect('equal') ax.axis('on') ax.grid(True, alpha=0.3) # ---------- 初始化绘图对象 ---------- circle_patches = [] # 存储所有圆 line_segments = [] # 存储圆心到圆周上点的连线 point_dot, = ax.plot([], [], 'ro', markersize=6) trace_line, = ax.plot([], [], lw=1.2, color='crimson') # 预创建图形对象 for _ in range(N_HARMONICS): c = plt.Circle((0, 0), 0.1, fill=False, edgecolor='steelblue', lw=0.8) ax.add_patch(c) circle_patches.append(c) seg, = ax.plot([], [], lw=1.0, color='gray', alpha=0.8) line_segments.append(seg) # 右侧波形数据的存储 time_phases = [] wave_values = [] def update(frame): t = frame / FPS # 计算当前所有圆的位置 positions = [] # 每级圆心位置 x, y = 0.0, 0.0 for idx, n in enumerate(n_values): circle_patches[idx].center = (x, y) circle_patches[idx].set_radius(CIRCLE_SCALE * radii[idx]) angle = omegas[idx] * t dx = CIRCLE_SCALE * radii[idx] * np.cos(angle) dy = CIRCLE_SCALE * radii[idx] * np.sin(angle) line_segments[idx].set_data([x, x + dx], [y, y + dy]) x += dx y += dy positions.append((x, y)) # 末端点位 point_dot.set_data([x], [y]) # 记录波形数据 time_phases.append(t) wave_values.append(y) # 只保留最近一段长度的曲线,避免右侧图太长 max_len = WAVE_SCALE * DURATION * FPS // 10 if len(time_phases) > max_len: time_phases.pop(0) wave_values.pop(0) # 将末端点的纵向位置投影到右侧波形坐标系 trace_x = [t * WAVE_SCALE for t in time_phases] trace_y = wave_values trace_line.set_data(trace_x, trace_y) return circle_patches + line_segments + [point_dot, trace_line] anim = FuncAnimation(fig, update, frames=FPS * DURATION, interval=1000 / FPS, blit=True) # 保存 GIF(需要 Pillow);如果想保存 MP4,可改用 ffmpeg 参数 anim.save("fourier_square_wave.gif", writer="pillow", fps=FPS) plt.show()这个脚本把所有权重都算好了,直接运行能出一个 12 秒的 GIF。我第一次跑通这个版本时,看到右侧曲线慢慢变成方波,内心还是很兴奋的。不过这个版本只画了右侧的“末端纵向轨迹”,如果你想在右侧也画出完整的“随着时间展开的波形”,并且让波形末端的点和左侧圆链末端用一条水平线连起来,还需要再加一条水平连接线,稍后我会说明。
3.3 关键参数怎么调:圈数、帧率、尾迹长度
首先是谐波数量 N_HARMONICS。我实测下来,N=3 时右侧波形还是明显的波浪状;N=7 时已经能看到方波的大概轮廓,但转折处有明显的过冲和振铃;N=15 到 N=30 是视觉和性能的甜点区;如果 N 超过 50,画面左侧的圆会密密麻麻,看似壮观,但圆与圆之间的半径越来越接近,视觉上已经很难分清层级,而且计算量会明显上升。建议第一次直接设 15,效果最好。
其次是帧率 FPS。30 帧每秒对 GIF 来说已经够流畅。如果你要导出 MP4,可以考虑 60 帧,因为视频压缩后会有损失。注意 GIF 文件体积:12 秒的 1440×720 GIF 很容易超过 20MB,如果你只是发社交媒体,可以把分辨率降到 800 宽,或者在保存时用 optimize=True。
最后是右侧波形线的横向拉伸系数 WAVE_SCALE。这个参数是我后加的,因为如果不拉伸,方波在右侧会非常“矮胖”,不方便观察波形。一般取 5 到 8 合适,太小波形挤在一起,太大曲线来不及展开就退出屏幕。
3.4 让动画更有“教学味”:连接线、高亮当前点与坐标标签
上面代码是基本版,如果用来教学,我建议再加三样东西:一是左侧末端和右侧波形末端之间的水平连接线,观众能直观看到“圆链末端的高度,决定了波形当前位置的值”;二是当前时刻波形上画一个大圆点,醒目一点;三是给每个圆的半径标上数值,比如 r=4/π、r=4/(3π),这能让观众直接对应到傅里叶系数。
连接线实现很简单,在 update 里记录 x 和 y,然后画一条从 (x, y) 到 (t*WAVE_SCALE, y) 的线即可。我加完这条线之后,整个动画的“因果感”立刻强了很多。你会发现,当末端点向上摆的时候,右侧波形也在往上长,两者是一一对应的。
connect_line, = ax.plot([], [], lw=0.8, ls='--', color='orange') # 在 update 最后: connect_line.set_data([x, frame / FPS * WAVE_SCALE], [y, y])高亮当前波形点也简单,在 trace_line 后面用同一个点坐标画一个浅色的大圆点作为光晕,视觉效果会好很多。
4. 常见问题排查与实操心得
4.1 右侧波形转折处为什么总是“凸起”?不是算错了
这是被问得最多的问题。当你叠加的谐波数量有限时,方波的边缘必定带过冲,这在数学上叫 Gibbs 现象。我最初以为是代码把相位写错了,后来把 N 加到 100 还是有过冲,才意识到这是固有现象。
具体表现是:波形接近方波跳变时,会有幅度约 8.95% 的过冲,不管加多少谐波都不消失,只是过冲振铃的宽度会越来越窄。很多教学动画会故意保留这个现象,因为它恰恰说明“有限级数无法完美表示不连续点”。你在 PPT 课程里甚至可以把 N 做两个版本,一个 N=5 一个 N=50,对比给学生看,效果远比空口讲 Gibbs 现象好。
4.2 圆链整体越转越偏,甚至跑出画面怎么办
如果你把圆链末端位置直接累加,会发现 P(t) 的实部会来回摆,但整体中心可能偏到左边或者右边,因为余弦项的加和并不以原点为中心。这个问题在动画里非常常见,尤其当 N 比较小时,末端点的水平坐标会有明显漂移。
解决办法有两个:一是加大左侧画布范围,比如把 xlim 设为 [-2, 16],y 轴不变,圆链会缩在小区域里转;二是对所有圆的初始相位做一个调整,让末端点在 t=0 时停在某个理想位置。更简单的做法是:给每个圆的位置累加前,减去多出来均值偏置。不过我不建议随便做数学变换,那会改变波形形状。我的经验是:动画的左侧区域放宽到 1.5 倍圆链总直径,基本不会跑出画面。
4.3 动画卡顿、GIF 文件过大、颜色不够清晰
卡顿一般出在两个地方:一个是 N_HARMONICS 太大,每一帧要更新几十个圆和线段;另一个是波形数据越存越多,导致绘图点数量爆炸。我上面的代码里已经做了 max_len 限制,防止数据无限增长。如果还是卡,可以把 blit=True 打开,它只重绘变化的区域,能显著提速。
GIF 过大则优先减小帧率和画布尺寸。如果你要的是高质量版本,建议直接保存 MP4 而不是 GIF。Matplotlib 保存 MP4 需要电脑里有 ffmpeg,命令改成:
anim.save("fourier_square_wave.mp4", writer="ffmpeg", fps=FPS, dpi=120)颜色方面,我有两个常用配色:学术风用黑色背景 + 白色圆 + 青色波形,比较接近 3Blue1Brown;简洁风用白底 + 蓝色圆 + 红色波形。圆太多时,可以把奇数项和偶数项画成两种颜色,不过在方波场景里全是奇数项,所以颜色相同没问题,但可以用深浅区分内外层,越外层的圆颜色越浅。
4.4 经常有人问:能不能让圆链末端直接在平面上画出方波轮廓
我想单独讲一下这个问题,因为它涉及对“旋转圆动画”的深层理解。如果你想让末端点在复平面上直接画出一个矩形轮廓,不能只用单侧频率的实系数叠加,因为标准方波的复数展开同时包含正频率和负频率。换句话说,你需要把每个 sin(nt) 拆成两个旋转方向相反的圆,每个圆半径减半,一个顺时针转,一个逆时针转,这样它们的水平分量抵消,竖直分量相加,末端点在复平面上画出的轨迹才会是上下跳变的矩形轮廓,而不是前面那种“圆链高度随时间变化”的展示。
这种双圆版动画视觉上更接近“画图机”,但代码复杂一些,需要维护正负两套圆。如果你只是科普傅里叶级数,我建议优先做“单侧圆链 + 右侧波形”版本;如果是为了展示傅里叶变换的复数本质,再去做双圆版本。
5. 不写代码也能做的几种思路
5.1 用 GeoGebra / Desmos 快速演示
如果你完全不想安装 Python,推荐用 GeoGebra 或者 Desmos。GeoGebra 可以这样操作:定义 n = 滑动条,从 1 到 30,步进 2;定义 r = 4/(π n);定义点 P = (r cos(n t), r sin(n t));再用序列求和或向量累加生成一串点,最后开启 trace 功能。GeoGebra 的 trace 可以让点留下轨迹,配合时间变量 t 的动画按钮,就能看到波形逐渐长出来。
GeoGebra 的好处是零代码,鼠标拖动滑动条改谐波数量还带实时交互,特别适合课堂临时演示。我实际试过用 GeoGebra 复现前 15 次谐波,效果非常不错。缺点是导出独立动画和自定义样式比较受限,最终成品的“质感”不如 Python。
5.2 PPT 手工模拟:能行,但只建议做概念版
网络热词里有不少人搜“ppt动画”,我猜也有一部分人想用 PPT 来做类似效果。坦率地说,PPT 非常适合做“圆的旋转”基础动画,但真正做到“末端点轨迹逐帧存储”非常吃力。你只能在 PPT 里预先画好几个不同半径的圆,然后用“动画窗格”里的“陀螺旋”或“路径动画”设置转速比例,让大圆转一圈、小圆转三圈,并且把圆的中心点和下一级的圆心锁定在一起。
实际操作上,你需要用到“组合形状 + 动画刷”,步骤大致是:先画一个半径 4/π 的圆,在里面画一个半径 4/(3π) 的小圆,小圆的圆心放在大圆边缘;然后对小圆添加“陀螺旋”动画,设置持续时间为大圆的 1/3,重复次数要相同。更麻烦的是末端轨迹,PPT 无法自动记录,你需要用“平滑路径”手动画一条近似方波,再用动画“擦除”逐步显示,效果会比较失真。所以我的结论是:PPT 做这个只是“形式上像”,真要体现数学,还是用代码或 GeoGebra。
5.3 网页端:Canvas 是正路,animation.css 只是玩具
如果你想把动画放到个人博客或者产品页上,JavaScript + Canvas 是最合适的选择,思路和 Matplotlib 完全一样:每一帧清空画布,循环绘制所有圆和线段,末端点位置用欧拉公式累加,然后画波形。Canvas 的好处是可以加鼠标交互,比如点击某个圆高亮对应谐波,这是静态教学资源做不到的。
至于 CSS 动画库,比如 animation.css,它只能做预设位移、缩放、旋转循环,无法做“多级圆周运动求和”的实时计算。你可以用 CSS 画出几个叠着的圆同时旋转,但末端轨迹的精确绘制基本得靠 SVG path 或 Canvas 逐帧绘制。如果你对 Web 前端不熟,我甚至建议不要硬啃 Canvas,直接用前面说的 GeoGebra 或者 Python 导出 GIF 后嵌入网页,省时又稳定。
最后再说一点个人体会
这动画我前前后后做了好几版,从最早用 MATLAB 乱改参数,到后来用 Python 写成带参数的脚本,感受最深的一点是:动画只是结果,真正值钱的是你动手推导方波傅里叶系数那一步。你把 r_n = 4/(πn) 这个式子写出来、再把 sin 拆成旋转圆投影的那一刻,傅里叶级数就再也不是抽象公式了。一个很实用的建议:不要一上来追求 50 个谐波,先跑 N=3,认真看看三个圆是怎么把波形扭出一个雏形的,再逐步加到 15。
如果你之后想让动画更高级一点,可以试试把右侧波形改成“可暂停 + 可回溯”,或者加一个谐波数量滑块,动态增减圆的数量,那种“边调边看波形变化”的交互感,比固定动画强得多。回来再重复一遍:这个动画的难点不在画圆,而在把数学公式翻译成逐帧的向量累加。只要过了这一关,以后你想画锯齿波、三角波、甚至任意周期信号,都是同一个套路。