2026/9/20 9:59:45

从像素跳动到稳定动画:Manim中离散运动的相位累加器实现

从像素跳动到稳定动画:Manim中离散运动的相位累加器实现 最近在调研数学动画视频软件时我把一个不起眼但特别影响观感的功能单独拎了出来研究——像素跳动。别看它名字朴素真要做好整个动画的质感和节奏感立刻不一样做不好再漂亮的公式推导也会被观众当成“屏幕自己在那儿抖”。这篇文章就是我这次调研的完整记录包含我在选型时踩过的坑、对像素跳动效果的技术拆解以及在 Manim 这类开源工具里的具体实现方案。这里先说明一下很多人一听到“像素跳动”会以为是像像素风游戏那种画面。不是的我这里的“像素跳动”指的是一类视觉效果的统称画面中的某个点、某条曲线、某个图形以离散的、肉眼可见的“跳跃”方式运动和变化。这类效果在数学视频里出现频率极高同时它也是动画引擎里最容易做糙、最难做精的部分之一。这篇文章的定位是给两类人看的一类是做数学视频、想把手上的动画工具用得更细的创作者另一类是本身对“离散运动如何平滑呈现”这个技术话题感兴趣、想了解背后原理的开发者。我会尽量用口语化的方式把原理、选型、代码和坑位都讲清楚。下面进入正题。第一部分先聊数学动画视频软件的整体现状和选型思路第二部分把像素跳动这个效果从技术原理上拆开第三部分给出一个在 Manim 里的可运行实现第四部分整理我在实际使用中遇到的高频问题和排查方法第五部分聊聊这个方向后续还能怎么扩展包括把硬件里的高精度波生成思路搬到动画控制里。1. 数学动画视频软件的现状与选型思路1.1 这类软件到底在解决什么问题数学动画不是普通 MG 动画它要表达的是数学对象之间的关系——函数曲线怎么变化、几何图形怎么旋转、数轴上的点怎么逼近极限……每一帧都必须符合数学语义不能像一般视频那样随便加转场、加光效。即便只是让一个点从 A 移动到 B如果运动路径不符合数学定义懂行的观众一眼就能看出问题。市面上能用来做数学动画的工具不少但真正专门为此设计的其实不多。我长期用的是 Manim因为它用 Python 写场景代码和我的工作流最贴合。但如果你想做实时交互式演示Manim 就不太合适GeoGebra 可能更适合你。所以选型之前一定要先想清楚“你最常做的动画是什么类型、是录制成片还是现场演示、你愿不愿意写代码”这直接决定了你应该选哪个工具。还有一个很多人忽略的点数学动画软件不只是“画图工具”它本质上是一个“面向数学语义的动画描述语言”。越接近数学语言表达习惯的工具越能让你把精力放在数学结构上而不是浪费在坐标系换算和图形细节上。这个认知会直接影响你对工具的评价标准。1.2 主流工具横向对比与选型考量我把自己实际用过的几款工具做了一张对比表。注意这里不是要告诉你哪个软件最好而是帮你根据自己最常做的场景做选择。工具上手难度适合场景主要优势主要坑Manim3B1B版/社区版中函数图像、几何证明、数列极限等代码可控公式渲染一流渲染较慢版本碎片化GeoGebra低课堂教学、动态几何演示交互式操作即时反馈复杂动画表达能力弱Processing中创意编程、粒子系统、数据可视化自由度极高适合自研效果需要自己搭大量数学逻辑Matplotlib FFmpeg低数据图表动画、论文配图与数据分析无缝衔接动画表现力弱Blender/Unity高三维数学结构、程序化建模3D 能力最强实时性好学习成本高非专用工具我的选型标准有三条。第一可编程性。数学动画里最难做的不是“画一个圆”而是“让圆按照某种规则去变化”这几乎必然要求你用代码来描述规则。第二公式渲染能力。LaTeX 公式在数学视频里是刚需工具如果不原生支持后续会非常痛苦。第三渲染管线可控性。我想要能控制每一帧的采样率和输出顺序这样在调试像素级效果时才不会被引擎的“智能优化”帮倒忙。最早我用 Processing 做数学动画它的像素操作非常自由但最大的问题在于坐标系统离数学坐标系比较远我需要反复做坐标变换代码里到处都是 map() 调用。后来转向 Manim发现它内置了坐标系、坐标轴、函数的采样与绘制大量数学动画可以像写作文一样直接描述出来。虽然 Manim 的渲染速度一直被吐槽但它的设计理念确实更适合“表达数学结构”而不是“自由作画”。1.3 为什么“像素跳动”成了绕不开的需求在做调研之前我本来以为像素跳动只是一个“偶尔用一下的小功能”。结果我把过往做过的数学动画复盘了一遍发现它无处不在。演示定积分的黎曼和时需要让一个个小矩形依次“跳”出来演示数列极限时动点需要一格一格地向极限值靠近演示坐标轴上的步进采样时采样点需要在曲线上跳跃前进演示分形迭代时点需要在屏幕上不断跳转演示随机过程时粒子需要以离散跳跃的方式运动。只要涉及“离散、逼近、迭代、跳变”这些概念像素跳动就是核心的表现手段。更关键的是这个效果对技术实现的细节极其敏感。一点点的坐标取整策略差异就可能导致画面产生肉眼可见的抖动或停顿。这促使我专门花了一整周时间把像素跳动从“会动”做到“动得有道理”。后面这部分就是我在这个过程中的完整拆解。2. “像素跳动”效果的技术拆解2.1 像素跳动的本质一个坐标函数的离散化问题从数学上看像素跳动本质上是在时间轴上对连续运动做离散采样。假设你有一个理想的连续轨迹函数 P(t) (x(t), y(t))渲染动画时只能在离散的时间帧上取值。所谓跳动效果就是采样后的坐标点之间存在肉眼可见的间隔。如果间隔很小、帧率够高人眼会将其视为平滑运动如果间隔较大或者节奏不均就会产生“跳动感”。那是不是只要帧率足够高一切跳动都没有了理论上是的但实际做不到。显示器刷新率是有限的常见的是 60Hz、120Hz而且数学动画里很多场景需要“刻意跳出离散感”比如强调数列的离散性时反而希望观众看到“一个点跳到了另一个点”的过程。所以问题的关键不是消除跳动而是让跳动的时机、幅度、节奏完全可控。这里还牵扯到一个“亚像素精度”问题。显示器最小单位是像素但数学计算是连续的。如果每帧都把浮点坐标四舍五入到整数像素就会引入量化误差。更麻烦的是量化误差会让小幅度运动产生“一卡一卡”的伪随机抖动这是很多新手动画看起来“很low”的根源。常见处理思路是先用浮点精度算完位置渲染阶段才做像素对齐并且考虑使用亚像素渲染技术比如对边缘做透明度渐变。2.2 步长、帧率、采样率之间的三角关系我习惯把控制像素跳动效果的三个核心参数称为“三角关系”步长决定单次跳动的位移量帧率决定每秒显示的帧数采样率决定数学对象本身的离散度。三者不是独立选择的它们互相制约。举个例子。假设你要做一条正弦曲线的采样动画相邻两个采样点在 x 轴方向上相差 0.1 个坐标单位y 值按 sin(x) 取值。如果你希望动画看起来“平滑”你应该保证每帧至少移动 0.05 个坐标单位也就是说步长不能大于 0.05否则会丢失中间状态看着就像“跳帧”。反过来如果你刻意要做“离散采样”的效果步长就应该大于坐标轴上一个像素单位并且要有意保留“跳上去”的瞬间。另一个容易忽略的参数是“坐标单位的物理像素映射”。同样一个步长如果你把画布缩放得很小可能只移动一两个像素肉眼几乎看不出来如果画布放大得很夸张一次跳跃可能横跨整个屏幕。所以在调任何跳动效果之前我建议先明确一个坐标单位对应多少个物理像素。Manim 里可以通过配置 frame_width 和 pixel_width 来反推这个映射关系。注意调参时建议固定帧率在 Manim 里就是设置 --fps 60。不然不同设备上跑出来的节奏完全不一样这也和嵌入式里的固定时钟思想一致——不加外部约束的动画效果无法稳定复现。2.3 DDS 相位累加思想让跳动产生“颗粒感”但不乱我在调研高精度波生成方案时看到嵌入式领域的一个做法非常值得借鉴——直接数字频率合成DDS。传统 DDS 的核心是相位累加器每次时钟到来相位寄存器累加一个频率控制字然后通过查找表输出波形幅度。它最大的优点是频率和相位可以做到极高的精度而且输出极其稳定。把这个思路映射到像素跳动上就是把“目标运动速度”当作频率控制字每帧把“相位”累加一次然后根据相位查找/计算当前应处的像素位置。这样做有三个好处。第一累加器是固定增量的运动节奏天然稳定不会出现忽快忽慢。第二相位可以保留小数精度真正的坐标取整只在最后一步做从设计上避免了量化误差累积。第三它可以很容易地叠加多个运动源比如“整体向右平移”加上“叠加正弦抖动”只需要两个累加器分别算完后相加即可。我在后面的 Manim 实现中就用了一个非常轻量的相位累加器函数效果相当理想。尤其在做“极限逼近”这种需要动点一点点跳向目标的场景时相位累加让每个跳跃间隔保持恒定观众能明显感受到“稳定的逼近节奏”而不是杂乱的点位乱跳。2.4 为什么“双缓冲”结构对动画流畅度至关重要说完了软件侧的原理我要再多说一句硬件侧。我调研嵌入式波形生成时看到 STM32H7 平台上的 DMAMUX 配合双缓冲机制能把 DAC 波形输出做到完全没有空隙一块缓冲正在被 DMA 送往 DAC另一块缓冲已经在后台被 CPU 填充好下一批波形数据等 DMA 传完就立刻切换。这个“边输出、边准备”的思路对动画渲染同样重要。如果渲染过程是“清屏-绘制-等待显示完成-再清屏”那么每帧之间必然出现明显的空隙视觉上就是闪烁或卡顿。正确的做法是维护两条数据链路当前显示的帧始终是完整的后台持续准备下一帧。Manim 的渲染管线虽然不直接给用户暴露双缓冲接口但我们在设计自己的动画循环时可以模仿这个思路不要在 update 回调里直接修改正在显示的位置数据而是先计算下一帧的所有值再一次性切换。这样就能避免很多莫名其妙的闪烁问题。后面我在实现像素跳动时也会遵循这个原则。3. 实操在 Manim 里实现可控的像素跳动3.1 环境准备与最小可运行示例我使用的是 Manim 社区版ManimCE安装没什么特别需要注意的直接 pip install manim 即可但建议先创建虚拟环境避免和系统 Python 库打架。另外Manim 的渲染背后依赖 FFmpeg、Pango 和 LaTeX如果你要做公式动画这三个都得装好。装完跑一句 manim --version 确认版本接下来开始写代码。我准备了一个最小示例让一个点以“像素跳动”的方式在数轴上移动。这个示例看起来简单但它是后面所有复杂效果的基础。先建一个场景文件比如叫 pixel_jump.py内容如下。# pixel_jump.py from manim import * class PixelJumpDemo(Scene): def construct(self): axis NumberLine(x_range[-5, 5], include_numbersTrue) self.add(axis) dot Dot(axis.number_to_point(0), colorYELLOW) self.add(dot) for i in range(1, 4): target axis.number_to_point(i) self.play( dot.animate.move_to(target), run_time0.3, rate_funcsmooth )这个示例简单直观但不要误解它就是“像素跳动”。这里用的是 Manim 内置的平滑插值点从 0 移动到 1、再移动到 2、再移动到 3视觉上是加速-减速-加速-减速的运动更像“滑动”而不是“跳动”。如果想要清晰的跳动感需要手动控制每一步的时长和中间状态。3.2 从静态图到动态跳动的核心代码要做出真正的“跳动感”而不是滑动我建议放弃 Manim 内置的 animate.move_to改用自定义的 updater。这样可以精确控制每一帧的位置变化。下面的示例实现了一个带相位累加器的“跳动点”它每一帧计算当前位置并且通过累加器控制每步的间隔让你能感受到清晰的、有节奏的跳跃。# pixel_jump_with_phase.py from manim import * import numpy as np class PhaseAccumulator: 极简相位累加器借鉴 DDS/频率合成思想。 phase_increment 相当于“步进量”决定跳动的速度。 def __init__(self, phase_increment0.01): self.phase 0.0 self.phase_increment phase_increment def step(self): # 累加相位并自动做周期回卷 self.phase self.phase_increment if self.phase 1.0: self.phase - 1.0 return self.phase class PixelJumpWithPhase(Scene): def construct(self): axis NumberLine(x_range[-5, 5], include_numbersTrue) self.add(axis) dot Dot(axis.number_to_point(0), colorYELLOW) self.add(dot) acc PhaseAccumulator(phase_increment0.005) target_positions [axis.number_to_point(i) for i in range(1, 4)] current_target 0 hold_time 0.8 # 在目标点停留的时间秒 def update_dot(mob, dt): nonlocal current_target, hold_time acc.step() if current_target len(target_positions): # 累加器输出接近0时表示“跳”的时机到了 if acc.phase 0.01: next_pos target_positions[current_target] mob.move_to(next_pos) current_target 1 dot.add_updater(update_dot) self.wait(3) dot.remove_updater(update_dot)上面这个版本故意写得“示意性”很强实际使用中你需要把相位阈值、保持时长、坐标映射都做成参数。重点在于整个跳动过程由相位累加器驱动而不是由随机计时器驱动因此每一步之间的节奏是严格恒定的这是“跳动感”看起来舒服的核心原因。3.3 参数调优怎么让跳动“有质感”而不是“瞎抖”代码能跑起来之后真正的考验来了参数怎么调我整理了一张自己在不同场景下的参数表可以直接参考。注意这些参数不是万能的但可以当作起点。场景步长跳动间隔缓动函数建议帧率说明黎曼和矩形累加0.1 坐标单位0.5 秒/次smooth60每个矩形“跳出来”而不是“滑出来”数列极限逼近逐步减半0.3 秒/次无缓动硬跳60强调“一格一格逼近”不加过渡随机游走演示3 像素0.2 秒/次线性30-60适合快速生成大量随机跳点正弦波采样点采样间隔的 1/20.4 秒/次ease_out_cubic60保留连续感但能看出离散采样我特别想提醒三个坑。第一个坑是“过度缓动”。很多人为了让跳动“柔和”给每次跳动都加上很长的缓动函数结果观众看到的不是“跳动”而是“蠕动”这违背了使用跳动效果的初衷。第二个坑是“坐标取整”。如果你在内部计算时就把坐标转为整数像素最终画面上会出现无法言喻的微小抖动。第三个坑是“步长和物理像素不匹配”。比如你的图像缩放后一个坐标单位对应 0.8 个物理像素这时 1 个坐标单位的跳动在屏幕上只有不到 1 像素看起来完全没动这种情况要把步长调成整数像素倍或者调整画面缩放比例。3.4 渲染管线与缓存策略多学一点“边输出边准备”的思路Manim 的渲染机制是先生成每一帧的图片再交给 FFmpeg 合成视频。如果场景复杂、对象多你会发现渲染时间长得让人怀疑人生。一个非常实用的技巧是在需要性能的场合不要实时渲染动画而是把每一帧的逻辑数据提前算好并缓存下来比如存成 npy 数组或 JSON然后再让 Manim 只做“把这些数据画出来”这一步。这本质上就是我前面说的双缓冲思想生产数据的链路和渲染显示的链路分离两边各自优化。我自己做过一个例子演示混沌系统时需要同时让几百个点按照洛伦兹方程运动。Manim 自带的实时 update 会撑不住我改成先用 NumPy 把未来若干秒的所有坐标位置批量计算完然后在一段 Animation 里按帧索引这些坐标“播放”渲染时间从十几分钟降到了几十秒而且播放极其流畅。这个方案适合任何需要大量点位移的场景尤其是像素跳动这种“点位很多、单点动作简单”的情形。这样做的另一个好处是调试方便。如果动画里出现了不该有的跳动你可以直接查看缓存数据确认是计算问题还是渲染问题。实际工作中很多“玄学抖动”其实都是渲染阶段的线程切换、内存分配卡顿造成的和算法本身无关。把计算和渲染分离后这些干扰因素就自然消失了。4. 常见问题与排查技巧实录4.1 画面闪烁、跳动不均匀是怎么回事画面闪烁最常见的原因是“每帧都重建对象”。在 Manim 里如果你在 updater 中使用了 self.add 或 self.remove 来更新目标就等于告诉引擎“我要创建/销毁 Mobject”这会引起重绘闪烁几乎不可避免。正确做法是只修改已有 Mobject 的属性比如 move_to、scale、set_color不要随意增删对象。另一个容易忽略的原因是渲染输出分辨率与原视频参数不一致。Manim 默认输出 1920x1080如果中途切换过视频滤镜或做了缩放边缘像素就会出现闪烁。排查方法很直接把某一帧静态图放大看如果发现边缘锯齿和闪烁区域都在同一像素边界上那基本可以断定是采样问题。4.2 为什么明明算法没问题点还是乱跳这种情况十有八九是数据竞争。我之前用多线程预计算数据时一个坐标数组被多个线程同时写入结果某些帧读到了中间值点就在两个正确位置之间乱跳。现在我的经验是能用单线程批量计算就不用多线程即使要多线程也要保证每个线程只写自己负责的那段数据别共享可变数组。这和嵌入式里 DMA 传输同一个缓冲区的道理类似——读写同一块内存却不加同步出事只是时间问题。还有一种“乱跳”是数值精度引起的。比如你要让动点在第 10000 帧跳到坐标 5如果直接累计坐标值浮点误差会越积越大。正确做法是记录“初始坐标”和“总位移”每一帧用初始坐标加上总位移乘以进度来计算而不是在上一帧坐标上不断累加。这个建议同样适用于任何需要长期运行的运动逻辑。4.3 Manim 慢得像蜗牛怎么优化Manim 的慢分成两种情况。一种是启动慢这通常是 LaTeX 首次渲染缓存的问题多跑几遍就好了。另一种是渲染帧慢尤其是复杂场景解决办法是“降对象数”和“升复用度”。比如曲线上几千个采样点不需要每个点都画成独立的 Dot可以用 VMobject 一次性绘制如果一定要画点也可以用 PointCloud 或圆点贴图代替。还有一点很关键在调试阶段用低分辨率加低帧率渲染。我通常用 --quality low --fps 15 来做预览确认无误后再开高画质。不要每次修改都在高画质下渲染这样一次预览就要十几分钟效率太低。这个习惯帮我节省了大量等待时间也让我更愿意反复调参最后出来的画面质感明显不一样。4.4 跳动节奏和音频对不上怎么办数学动画经常配语音讲解如果跳动节拍和语音对不上观感会很奇怪。我一般会先确定每段讲解词对应的时间轴再回头给动画每个跳动设置精确的时长。比如“每讲到‘下一步’这个词时触发下一次跳动”。在 Manim 里可以直接用 self.wait 和 run_time 来控制时长但更推荐的做法是把所有关键时间点先列成一个表写成一个等待队列再用累加器判断当前是哪个时间点代码维护起来非常清楚。这里有一个小技巧不要依赖播放器的进度来同步因为不同播放器对同一条视频的时间戳处理有差异。正确做法是在视频输出阶段就把关键帧对齐好必要时直接做“帧级别”的动画时间轴保证每个跳动严格落在指定帧上。5. 这个方向还能怎么扩展5.1 把“像素跳动”做成通用动画原语调研完之后我把 PixelJump 封装成了一个可复用的 Manim 扩展模块。输入是一个目标点列表和每步的间隔输出是一段带节奏的跳动动画。这样做的好处是以后再写黎曼和、极限逼近、采样演示时只需要传参数不用每次重写 updater。封装时的几个关键设计一参数统一用“时间单位”而不是“帧数”这样不同帧率下表现一致二支持“硬跳”和“软跳”两种模式硬跳就是不插值、瞬间切换软跳则是给一个很短的过渡三允许叠加多个跳动源比如同时做横向和纵向的两个独立跳动最终位置是两个源的叠加值。这些设计都是用来对标 DDS 的多路信号叠加能力的实际用起来扩展性很强。5.2 从软件到硬件用实时设备做“数学动画播放器”调研过程中我翻了翻嵌入式领域的波形生成方案发现 STM32H7 结合 DMAMUX 双缓冲与 DDS 技术能在 DAC 引脚上输出高精度的连续波形。这个概念让我很兴奋如果把“像素跳动”的驱动逻辑搬到单片机上相位累加器就是现成的数学核心DMAMUX 双缓冲可以用来切换点阵屏的帧缓冲DDS 用来生成精确的坐标轨迹。这样就能做一个不依赖 PC 的实时“数学动画播放器”控制 LED 点阵或小型桌面像素屏显示实时的函数曲线和离散点跳动。当然这条路对硬件动手能力有要求。大致框架是先在一块 STM32H7 开发板上把 DDS 相位累加器跑通输出一个周期的正弦波到 DAC再把累加器输出映射到点阵屏的行列坐标最后把 DMAMUX 配置成双缓冲模式一块屏幕在刷新另一块在后台生成下一帧。整个过程和我在 Manim 里做的事情本质是一样的只不过把“帧”换成了“硬件更新周期”把“渲染管线”换成了“DMA 传输”。我目前还在实验阶段但已经验证了这不是噱头后续如果能跑通我会再写一篇专门的文章分享细节。5.3 从动画到工具链半自动化生成数学动画最后想聊一个更大的方向。很多数学动画视频其实有大量重复的模式画坐标轴、画函数、标极限点、做逼近动画、做采样动画。如果把这些模式全部模板化理论上可以通过一个简单配置文件自动生成整段动画。像素跳动做成原语只是其中一环整个工具链还包括函数解析、自动采样、公式排版、节奏控制。我理想中的状态是我只需要写“展示 y sin(x)/x 在原点附近的极限”工具会自动生成数轴、函数曲线、逐步逼近的点以及合适的跳动节奏。这个方向目前还比较粗糙但我觉得它是数学动画视频软件下一步的必经之路。相比那些商业动画软件开源工具的优势就在于这种“可编程、可复用、可深入改造”的能力。像素跳动虽然只是个很小的功能点但它恰好串联起了数学建模、动画渲染和硬件控制这三个层面这也是我为什么愿意花一整篇的篇幅来拆它。正经的“总结”我就不写了做技术的人都清楚这类文章最怕变成 PPT。我最后只分享一点个人体会这次调研让我发现一个看起来简单的视觉效果一旦较真起来背后可以牵扯出这么多东西。尤其是把 DDS、双缓冲这些“硬件思维”搬到动画引擎里之后很多以前靠经验瞎调的问题现在都有了理论上的解释和稳定的处理手段。如果你也在折腾数学动画我建议你不要只盯着“看起来炫”的效果多花点时间研究那些极简的、朴素的运动是如何被精确控制的——这往往才是一门工具真正值钱的地方。