资讯动态

数学动画中的像素跳动:从缓动函数到渲染管线的实现笔记

发布时间:2026/9/20 22:21:06 来源:尧图企业网站定制
做这轮调研的起因很简单我想做一组讲函数极限的短视频。视频里需要出现一条曲线曲线上的点一个一个“跳”到最终位置而不是平滑地划过去。我最早以为这个效果就叫“像素跳动”查了一圈才发现它其实是数学动画视频软件里一类很具体的需求把抽象数据转成有颗粒感的视觉像素再让这些像素按数学规律动起来。为了把这件事做利索我把主流的数学动画视频软件都翻了一遍也亲手在 Python 里跑了一条从点阵到成片的渲染管线。中途还看到嵌入式社区在聊 stm32h7 结合 dmamux 双缓冲与 dds 技术实现高精度波生成原本以为八竿子打不着结果发现动画渲染和波形生成本质上都在处理同一个问题如何把离散数据按固定节奏送到输出端并且保证连续、不撕裂。这篇文章就当是我把这轮调研里的选型、代码、踩坑和跨领域思路都做个存档。1. 先搞清楚“像素跳动”到底在跳什么1.1 数学动画里的三种“跳”“像素跳动”这个词在中文社区里没有一个严格定义搜索时经常混进粒子动画、位移动画、MG动效一类的内容里。就数学动画的实际场景来说我把它拆成三种第一种是数值跳变。函数图像在某个点发生跃迁数列收敛到极限时点从一个状态瞬间切到另一个状态。这种跳动通常不是物理位移而是数学语义里的“离散”。它不需要弹性甚至不需要过渡关键是干净利落地跳过去让观众看到“原来这里有一个断点”。第二种是落位跳动。动画开场的各个点从画面外或者统一起始位置“跳”到各自的函数位置上落地时带一点回弹。这是最接近大家口里“像素跳动”的效果也是我用得最多的一个。它把静态的曲线变成了一群有节奏感的点数学关系不变但观看体验变得更有层次。第三种是像素游走或者说抖动。点阵里的每个像素围绕某个中心做轻微随机移动常用于误差带、概率分布、噪声数据等内容的可视化。它看起来像是颗粒自己在呼吸目的不是表现运动而是表现不确定性。把三种跳分开后面的技术选型才不会乱。比如落位跳动适合用缓动函数数值跳变适合用逐帧截断像素抖动则必须依赖伪随机数和独立的运动相位。市面上很多教程把三者混在一起讲导致一开始我在拟合代码时经常拿到错误参数。1.2 为什么这个效果对数学表达很重要有人会问做数学动画为什么非要让像素跳一下慢慢飞过去不是更顺滑吗我最初也这么想直到拿线性移动和 Bounce 缓动各渲染了一版对比后才发现问题。线性移动的轨迹确实是连续的但人的视觉注意力对匀速运动很容易疲劳。当一群点同时从画面底部匀速浮起来观众很难在短时间内判断“哪个点对应哪个位置”注意力会被“运动过程本身”吸引走。反过来如果用跳动加回弹视觉系统会在极短时间里锁定“落点”因为弹跳的回弹信号等于告诉大脑这里是目标位置附近的轨迹都只是引导。数学动画和普通 MG 动画最大的区别就在这里。普通动画追求的是“动作丝滑”数学可视化追求的则是“关系明确”。像素跳动表面上是制造动感实质上是在制造视觉锚点。这决定了后面的缓动函数设计不能照搬游戏 UI 里的那套不能为了好看牺牲信息层级。2. 主流数学动画视频软件的“像素跳动”能力摸底2.1 工具分三个阶段看别指望一个软件全包在调研工具时我第一个错误是希望某一个软件可以同时完成验证公式、做动效原型和输出成片。实际用下来几乎不可能。matplotlib 适合验证公式出图科学但动效性能差。manim 适合把数学推导过程做成漂亮的视频但它的渲染逻辑建立在矢量对象上离“真正操作一个屏幕像素”很远。p5.js 和 Processing 有完全自由的像素控制适合做原型和艺术向表达但要导出成熟视频还得多走一步。GeoGebra 和 Desmos 则更适合交互验证参数滑杆一拖就能看到变化视频输出能力和自定义缓动能力几乎为零。所以我后来的选型逻辑是用 GeoGebra 或者 Desmos 快速看参数范围用 p5.js 做像素级原型用 manim 做数学推导的主体视频最后如果要做批量数据动画就回到 Python 逐帧渲染。下面这张表是我调研后的一个浓缩版工具适用阶段像素级控制自定义运动曲线直接出片manim数学讲解成片弱基于矢量对象强内置 rate_func强自带动画输出matplotlib数据验证弱依赖 scatter一般需手工实现一般可用 moviepy 编码p5.js / Processing原型与像素实验强直接操作 pixels强可写任意函数一般需 CCapture 或 OffscreenCanvasGeoGebra / Desmos参数探索弱弱弱After Effects片头包装中靠表达式控制强有表达式和关键帧强2.2 manim最像“数学语言”但离像素最远manim 是 3Blue1Brown 带火的数学动画引擎我一开始对它的期待很高因为它就是为数学讲解而生的。实际写了几段动画后我意识到它的核心模型是“矢量图形场景”所有运动对象都是VMobject。在这种模型下你想让一个点跳动只需要写一个很短的场景from manim import * class BounceIn(Scene): def construct(self): d Dot(3 * DOWN, radius0.12, colorYELLOW) target 2 * UP 2 * RIGHT self.add(d) self.play( d.animate.move_to(target), rate_funcrate_functions.ease_out_bounce ) self.wait()这段代码跑起来点的运动就是带弹跳的落位效果很干净。但注意manim 里的“点”在渲染后仍是矢量的圆放大多少倍都不会变成马赛克像素。如果你追求的是那种颗粒感、扫描感、老式像素屏点亮效果的数学动画manim 并不是最佳选择它更适合做公式推导和几何变换。另外一个问题是 manim 的缓存机制。它会把每一帧渲染成 PNG 后合视频虽然自带 rate_func但如果你要精确控制每个点不同的延迟相位得自己写 updater让每个Dot持有独立的进度值。这能实现但等于把一个像素引擎的活硬塞进矢量引擎里代码复杂度会涨得很快。2.3 p5.js 和 Processing把“像素”握在手里如果你想看真正的像素块像荧光粉一样离散地亮起来p5.js 或者 Processing 是更顺手的选择。p5.js 里可以通过pixels[]直接读写画布上每个点的 RGBA 数组配合noSmooth()可以把点阵做得非常硬核。比如你可以在setup()里生成 512 个点每一帧修改它们的屏幕坐标然后用stroke()画出 2x2 的方块来模拟像素块。它的优势是自由度极高任何你能写出来的位置函数都能驱动像素劣势是浏览器环境下的视频导出需要额外依赖比如 CCapture.js而且它录制的帧率受显示器刷新率影响容易丢帧。Processing 的情况类似。它作为 Java 生态的创作工具操作像素和渲染帧缓冲都很直接适合做桌面端原型。如果你不想花时间搭视频编码流程Processing 可以先输出 PNG 序列再交给 ffmpeg 合成效果和 Python 管线是一样的。2.4 被低估的 GeoGebra 和 Desmos这两个工具我放在最后说是因为它们很容易被忽略但在调研阶段非常好用。GeoGebra 的滑动条可以绑定函数的某个参数拖动时整个曲线实时更新。这个特性特别适合在写正式动画之前确认“我到底要让哪个参数动”。同样的Desmos 允许你快速输入多个函数直接看到曲线形状还能用播放按钮让参数自动变化。它们的共同缺点是没有逐帧渲染接口没有自定义缓动函数也不能直接输出高清视频。把它们当计算器和草稿纸而不是当成片工具效率最高。3. 用最朴素的渲染管线把像素跳动跑通3.1 目标效果和工具选型软件摸了一圈后我决定先脱离那些大引擎用最朴素的 Python 逐帧渲染管线把效果验证一遍。目标很明确用 200 个点表示一条函数曲线每个点从画面底部跳到自己对应的目标位置整体时长 3 秒30 帧每秒1280x720 分辨率。选 Python 加 NumPy 加 Pillow 的原因有三个。第一NumPy 可以一次性生成所有采样点的坐标比在 manim 里逐个写对象更接近“数据驱动”的思维。第二Pillow 的绘图 API 足够轻能在一秒内画出两百个圆点性能足够完成原型验证。第三这种组合不绑定任何视频平台渲染出的 PNG 序列可以交给任何工具去合成排查问题也简单。3.2 坐标映射和缓动函数渲染代码的核心是先建立“数学坐标”到“屏幕坐标”的映射。我这里把 x 轴范围设为 -6 到 6均匀采样 200 个点。函数取y x^2 * sin(x)经过归一化以后映射到屏幕高度中间的位置让曲线能稳定出现在画面里。动画进度用的是最经典的 ease_out_bounce。它的数学形式是一组分段二次函数在 t 接近 1 的时候会产生几次递减的回弹。之所以选它是因为它把“落位”这件事表现得非常明确先快速冲到目标附近再小幅反弹几次最终停在目标点。这个回弹过程在数学动画里不会显得多余反而能强调“最终位置”在哪里。import numpy as np from PIL import Image, ImageDraw W, H 1280, 720 FPS 30 DURATION 3 FRAMES FPS * DURATION N 200 x np.linspace(-6, 6, N) y_curve (x ** 2) * np.sin(x) y_norm y_curve / np.max(np.abs(y_curve)) def ease_out_bounce(t): n1 7.5625 d1 2.75 if t 1 / d1: return n1 * t * t elif t 2 / d1: t - 1.5 / d1 return n1 * t * t 0.75 elif t 2.5 / d1: t - 2.25 / d1 return n1 * t * t 0.9375 else: t - 2.625 / d1 return n1 * t * t 0.984375 for frame in range(FRAMES): image Image.new(RGB, (W, H), (18, 18, 24)) draw ImageDraw.Draw(image) t (frame 1) / FRAMES for i in range(N): progress ease_out_bounce(t) sx (i 0.5) / N * W sy H * 0.85 ex sx ey H * 0.5 - y_norm[i] * H * 0.35 px sx (ex - sx) * progress py sy (ey - sy) * progress r 3 draw.ellipse((px - r, py - r, px r, py r), fill(255, 170, 40)) image.save(fframes/f_{frame:04d}.png)这个脚本跑起来之后你会在第一帧看到底部出现一排气球一样的点它们同时向上冲然后以几乎一样的节奏落到曲线位置。整体效果已经能看出“像素跳动”的雏形但观感还差一截因为所有点用同一个t所以所有点就像被同一根指挥棒带着走缺少交错感。这个问题会在后面用相位和延迟来解决。3.3 用 ffmpeg 把 PNG 序列变成视频逐帧生成的 PNG 只是素材要变成能直接放进剪辑软件的视频我用 ffmpeg 合成ffmpeg -framerate 30 -i frames/f_%04d.png -c:v libx264 -crf 16 -pix_fmt yuv420p -r 30 pixel_bounce.mp4这里的-framerate 30告诉 ffmpeg 把输入 PNG 序列按 30 帧每秒读取-r 30又给输出文件加一道固定帧率约束避免生成可变帧率文件。-pix_fmt yuv420p是为了兼容性几乎所有剪辑软件和播放器都能正常播放这种格式。如果不加-r 30在某些播放器里视频速度会出现细微偏差这是因为 PNG 序列本身不携带时间信息输出端默认帧率可能与输入不一致。也就是这一行命令的差别让我第一版视频在手机播放器里比预期慢了差不多半秒。3.4 为什么要先落盘 PNG而不是直接压视频也许有人会觉得Python 已经渲染出帧了直接交到 OpenCV 或者 moviepy 里编码不就行了吗我实践下来还是推荐先落盘 PNG。原因很现实逐帧渲染加视频编码同时进行时调试成本会翻倍。一旦编码器出错你不知道是渲染逻辑的问题还是编码参数的问题。先落盘 PNG 后每一帧文件都是独立产物哪一帧有问题可以直接打开看图确认全部帧无误后再一步到位压视频整个流程的排查边界非常清晰。代价是磁盘空间大一些。200 帧 PNG 大概几十 MB和视频渲染相比完全可以接受。4. 让它“有数学感”的关键缓动、错峰与相位4.1 缓动函数不是越弹越好第一版把 ease_out_bounce 用上去之后曲线确实生动了但我觉得弹得太夸张。标准 bounce 会弹三下落在数学曲线上时有点像弹簧玩具反而弱化了“点属于曲线”这一层数学关系。我后来做了三版对比缓动函数观感适合场景linear机械、平均像采样扫描展示匀速扫描过程ease_out_cubic快到慢平滑落位正式数学讲解默认选择ease_out_bounce落位明确但弹跳明显开场动画、强调最终位置如果视频内容是函数极限、数列收敛这类严肃话题我会把 bounce 的回弹幅度砍半让点先冲到目标位置的 1.15 倍再落回目标点而不是标准的多次回弹。这个小细节能让动画保持动感又不会让观众把注意力全放在弹跳上。4.2 错峰用“随机延迟”打破齐步走全场 200 个点同时起跳观感上很整齐但也很单调。整齐本身不是问题问题是它抹掉了“每个点是独立个体”的感觉。数学曲线上的每个点都对应一个 x它们天然有顺序关系所以让相邻点稍微错开一点起跳时间会更容易让观众顺着 x 方向去读曲线。我在这里引入了一个延迟数组用固定随机种子生成确保每次渲染结果一致rng np.random.default_rng(20240512) delay rng.random(N) * 0.3 for frame in range(FRAMES): t (frame 1) / FRAMES local_t np.clip((t - delay) / (1 - delay), 0.0, 1.0) ...延迟上限设为 0.3 秒意味着前 0.3 秒已经有一部分点开始运动而不是所有点都齐刷刷等待。随机种子固定以后无论重跑多少遍得到的错峰顺序都完全一样。这点在数学动画里很重要你希望动画可复现不希望每次运行都产生一种新的随机序列否则视频没法稳定生产。4.3 残影和背景网格让跳动变成“生长”当点都跳到目标位置后画面是一堆离散的点它们还没有自动连成曲线。如果你想让观众看到整条曲线的生长过程可以不用画线而是利用残影。做法是在每一帧渲染时不要新建全新背景而是把上一帧的图像以很低的不透明度贴回当前画面。这样每个像素块落地后都会留下一层淡影新点继续往目标位置跳最终画面会形成一条由残影连成的发光轨迹。这个效果特别适合展示多项式拟合、数值逼近、递推过程因为它把“中间走过的路”保留了下来。5. 实际渲染中踩过的三类坑5.1 落点附近来回闪不是逻辑问题是像素取整问题第一版渲染完成后我放到播放器里逐帧看发现一些点快到达目标位置时会在附近来回抖动一两个像素。检查代码后确认不是动画逻辑错乱而是坐标没有取整。Pillow 的ellipse支持浮点坐标但最终光栅化时还是要落到整数网格。当目标位置带小数时相邻两帧的取整结果可能差一个像素表现在视频里就是轻微跳动。解决办法有两种一是把最终坐标取整后再传给绘图函数二是做 4 倍超采样渲染缩回 1280x720 时顺便消除锯齿。对于这种教学向动画我倾向超采样。渲染开销大一点但曲线边缘顺滑很多而且像素跳跃带来的颗粒感可以靠缩小点半径保留不会被磨平。5.2 编码后边缘发虚H.264 色度抽样搞的鬼压完视频后我发现同一帧 PNG 在图片查看器里很锐利变成 MP4 后边缘有点发糊。这不是渲染问题而是编码参数问题。H.264 的默认色度抽样通常是 4:2:0也就是说亮度的分辨率大于色度分辨率细小的橙色点在蓝色背景上容易显得边缘不够干净。对数学动画来说点阵本来就小再被色度抽样磨损一圈观感会差不少。我最终的做法是先用高质量 PNG 序列渲染然后在 ffmpeg 里把-crf降到 16 或者 14不要用默认的 23。如果你对色彩极其敏感可以考虑输出 ProRes 422 或者直接保留 PNG 序列到剪辑软件里再做最终编码。5.3 不同播放器速度不一致检查有没有固定帧率前面提到过-r 30这个参数。让我真正记住这个坑的是同一段 MP4 在 QuickTime 里播放正常在另一款播放器里明显偏慢。原因就是编码出的文件被识别成可变帧率播放器的帧率协商方式不同导致速度偏差。所以在 final 编码时我会同时保证输入和输出都有显式帧率。如果是给剪辑软件用的中间素材我甚至会直接用 PNG 序列不在这一步压成视频由剪辑软件统一决定帧率。下面是这一段经验的汇总现象可能原因我的处理办法点落位后周围闪烁浮点坐标取整抖动坐标取整或 4 倍超采样成片边缘发虚色度抽样和码率不足-crf 14~16必要时用 ProRes播放速度不对可变帧率输入输出都显式指定-r 30某段掉帧明显渲染缓存不足分批渲染或降低批次点数6. 从数字波形生成里偷师双缓冲与 DDS 的动画渲染启发6.1 一次关于“数据怎么送到屏幕”的联想调研到一半时我看到嵌入式社区在讨论 stm32h7 结合 dmamux 双缓冲与 dds 技术实现高精度波生成。这个词站在动画软件的角度看非常陌生但仔细一想波形生成和动画渲染面对的问题是同一个用固定的时间步长把一连串离散数值连续地送到输出端并且在时刻切换时不能出现断层或者毛刺。DMA 双缓冲的做法是在 DMA 控制器输出缓冲区 A 里的波形数据时系统提前把下一段数据准备好放进缓冲区 B。等到 A 输出完毕指针瞬间切到 B中间没有等待间隙没有数据断层。放到动画里这对应我始终在渲染循环里保留两个帧缓冲一帧保存、一帧绘制避免画面撕裂和计算阻塞。DDS也就是直接数字合成核心是一个相位累加器。系统的时钟每走一步相位累加器就加一个频率控制字然后用相位值去查波形表输出对应的幅值。频率高低取决于累加器每次加的增量而不是靠临时改输出值。这种方式最大的好处是相位连续信号不会在波形边界出现跳变。6.2 把“相位累加器”用于像素跳动我在第三部分的代码里动画进度用的是全局统一的t frame / FRAMES。这种方式简单但每个点的运动总是从 0 到 1 走一遍和 DDS 的相位累加器相比缺少“各自独立频率”的表达。如果在 DDS 思路里重做像素跳动每个点应该有自己的相位和频率控制字phase rng.random(N) freq 0.85 0.3 * rng.random(N) for frame in range(FRAMES): phase (phase freq / FPS) % 1.0 for i in range(N): progress ease_out_bounce(phase[i]) ...这里的phase就相当于 DDS 里的相位累加器freq相当于频率控制字ease_out_bounce相当于波形查找表。每个点以略微不同的速度在 0 到 1 之间循环虽然起点不同、节奏不同但它们都在同一套缓动函数上取值整体还是“同一个发生器发出来的”运动。把动画进度从全局 t 改成独立相位之后画面会从“一支合唱队”变成“一团有情绪的粒子群”但又不至于杂乱因为运动规律统一。6.3 双缓冲在脚本里的落地我在 Python 管线里复刻双缓冲的方式很简单准备两个PIL.Image对象一个负责当前帧绘制一个负责上一帧残影计算。当前帧绘制完成后立刻交换引用保存上一帧只是从邮箱队列里取出来交给编码器不让 IO 阻塞绘制。如果你在脚本里用的是一个高性能管线的思路那还可以把帧数据写入共享内存或消息队列用子进程去消费并交给 ffmpeg。硬件里的 DMA 双缓冲本质上也是“生产者—消费者”模型只不过它保证生产者永远比消费者提前一个完整缓冲区的距离。动画渲染如果想要不卡顿同样要保证预渲染的数据比播放头至少多出一到两帧。7. 选型之外的几句经验话这轮调研做下来我最大的体会是即使是“像素跳动”这样一个听起来很小的效果也横跨了数学函数设计、渲染引擎选型、视频编码和底层数据流四件事。如果你也是刚开始做数学动画视频软件调研我会建议先用最简单的 Python 脚本把 200 个点跳起来跳到线条大致成形之后再去折腾 manim 或者 p5.js。不要一开始就安装一堆大软件也不要只在将就着可用的现成效果里找答案。当你能通过一个 3 秒的片段控制每个点的相位、缓动、残影和编码效果时再回头看 manim 的rate_func、或者 p5.js 的pixels[]都会觉得只是换了一层表现皮肤底层的运动逻辑完全互通。还有一个小技巧想分享在真正剪辑前把动画的所有运动参数写成一份 JSON。里面不用存坐标只存点数量、延迟种子、频率范围、缓动函数名和最大偏移量。这样不管换到什么软件你都能用同一套参数重新生成一模一样的动画。对数学视频来说这一条尤为重要因为它意味着任何一个画面都可以追溯回一组可验证的数学设定而不是一段不可复现的“玄学动画”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价