1. 中间帧到底在解决什么问题
图形学课程里做动画实验,绕不开一个概念:中间帧(in-between frame)。手绘动画时代,原画师只画关键姿势,比如角色抬手和放下的两个极端状态,中间那些过渡画面交给助理画师去补。到了计算机图形学里,这个"补画"的工作交给了插值算法。我想在这篇里把整个从零实现到完善的过程完整梳理一遍,最后你会发现,中间帧生成不只是图形学作业,也是任何形变动画、补间动画、形状渐变系统的地基。
先说清楚我们做的事:给定两个关键帧(也称 keyframe),每个关键帧包含一组顶点坐标和颜色信息,程序自动生成它们之间的 N 个过渡帧,让物体从第一帧的形态平滑演变到第二帧的形态。这是图形学实验里最常见的题目之一,但它深入下去涉及的问题一点都不基础:顶点如何一一对应、坐标如何插值、颜色如何过渡、旋转信息如何处理、怎样控制动画节奏,每一个都是可以深挖的方向。
我用 Python 完成这个实验,因为 Python 做原型效率确实高。核心数学运算用 NumPy 处理,渲染输出用 Pillow,程序把生成好的帧存成 PNG 序列,再用工具合成动图或视频。整套流程下来对依赖的要求极低,纯 Python 环境就能跑。如果你的图形学课程正好要交中间帧相关的实验,或者你在做自己的补间动画工具,这篇文章的思路可以直接抄。
先说几个必须区分清楚的概念。中间帧生成通常分两种路线:一种叫"形状插值"(shape interpolation),处理的是多边形顶点位置的渐变,物体从三角形变成五角星这类;另一种叫"变换插值"(transformation interpolation),处理的是整个物体的平移、旋转、缩放,物体本身形状不变,只是位姿在变。实验通常要求两种都覆盖,我的做法是把它们统一成一套框架:所有参与插值的属性都抽象成一个可插值的数值序列,不管是坐标、角度还是透明度。
2. 环境准备与数据表示设计
2.1 工具链选型:NumPy + Pillow 的组合逻辑
做图形学实验,环境搭建这一步其实最简单,但也是很多人一上来就卡住的地方。我这里用的是最朴素也最稳定的一组库:
- NumPy:所有顶点坐标、颜色值都组织成数组,插值运算直接走向量化计算,性能好且代码简洁
- Pillow:负责画图,把每一帧渲染成位图,支持保存为 PNG 序列
- 可选 imageio 或 OpenCV:把 PNG 序列合成视频,如果只是看效果,可以用 Pillow 直接存 GIF
不需要安装任何“图形学专用”的庞然大物。中间帧生成的核心是数学计算,不是渲染器——那些全局光照、光栅化管线在这个实验里用不上。选 Pillow 而不是 matplotlib 的原因是它更接近“直接操作像素”的语义:我可以创建一张空白 RGB 画布,通过ImageDraw模块在上面画多边形和线段,这对帧序列生成特别自然。matplotlib 适合数据可视化,做动画帧渲染反而要额外处理坐标轴、边框这些东西,徒增烦恼。
如果你用的是 Anaconda 环境,直接:
conda install numpy pillow或者普通 Python 环境下:
pip install numpy pillow装完验证一下能不能画第一张画布不报错就行。我建议全程用同一个虚拟环境,别混着装,后面出问题排查起来麻烦。
2.2 关键帧的数据结构:Vector 类和 Keyframe 类
在设计数据表示这一步,我踩过一次大坑。最初图省事,把关键帧直接存成二维数组(顶点坐标列表),写着写着发现不对——不同的插值类型(位置、旋转、颜色)混在一个数组里,代码里到处是 if-else 判断“这条属性该走哪种插值”,不仅乱而且没法扩展。
后来我重新设计了两个类,非常朴素但好用:
import numpy as np from dataclasses import dataclass, field from typing import List @dataclass class Vector: """三维向量,加法和数乘都逐分量完成,用于坐标和位移插值""" x: float = 0.0 y: float = 0.0 z: float = 0.0 def __add__(self, other): return Vector(self.x + other.x, self.y + other.y, self.z + other.z) def __mul__(self, scalar: float): return Vector(self.x * scalar, self.y * scalar, self.z * scalar) def __rmul__(self, scalar: float): return self * scalar def lerp(self, target: "Vector", t: float) -> "Vector": """ lerp 全程 linear interpolation,对每个分量独立做线性映射。 t=0 时返回自己,t=1 时返回 target,中间值按比例过渡。 """ return self * (1 - t) + target * tdataclass是 Python 3.7+ 才有的特性,这应该不是问题。用 class 来封装向量的原因是数学意图清晰:坐标和位移是“数值”,旋转是“角度”,透明度是“标量”,三者的插值算法完全不同,放到一起只会互相纠缠。这里我没用 numpy 的ndarray做分量表示,虽然更简单,但类型太模糊,调试的时候很难一眼看出某个数据的语义。
然后是关键帧类:
@dataclass class Keyframe: """描述某一时刻物体的完整状态""" time: float = 0.0 # 时间戳,单位秒 position: Vector = field(default_factory=Vector) # 位置向量 rotation: float = 0.0 # 绕屏幕中心的旋转角(弧度制) scale: float = 1.0 # 缩放因子 color: tuple = (255, 255, 255) # RGB 颜色 vertices_group: List[np.ndarray] = field(default_factory=list) # 每组顶点是一个 Nx2 数组,列表支持同一时刻存在多个子形状这个类承担两个职责:记录关键帧的语义信息(在哪、多大、什么颜色),也记录几何数据(顶点组)。vertices_group用列表装多个数组,因为动画里的物体往往不是单一多边形——一个简化的小车就至少需要车身矩形和车轮圆形两组几何数据。
实际使用时,关键帧数据一般从外部文件读入。我习惯用 JSON 存:
{ "time": 0.0, "position": [0, 0, 0], "rotation": 0.0, "scale": 1.0, "color": [100, 200, 255], "vertices": [ [[0, 0], [50, 0], [50, 50], [0, 50]], [[10, 10], [40, 10], [40, 40], [10, 40]] ] }这里 “时间” 的含义值得一提:在这个实验里,时间并不是现实世界的经过时间,而是关键帧在动画序列中的位置。假设动画总时长 2 秒,第一个关键帧在 0 秒,第二个在 2 秒,我们指定帧率为 30fps,那中间要生成 60 帧画面。某个中间帧的插值参数t = (当前帧时间 - 起始帧时间) / (结束帧时间 - 起始帧时间),范围一定在[0, 1]之间。
2.3 为什么所有属性都要能插值
关键帧是一切动画系统的起点,但光有关键帧没有中间帧,动画就是一张张静止画面的生硬切换,完全谈不上流畅。中间帧生成要做的,就是为任意时间t找到所有属性的过渡状态,让画面从关键帧 A 平滑走到关键帧 B。
核心数学工具是线性插值(lerp):
lerp(a, b, t) = (1 - t) * a + t * b其中t从 0 变到 1。当t=0时得到a,t=1时得到b。图形学里几乎所有连续动画都建立在这个简单公式之上,区别只在于对哪些属性用、参数怎么调。位置用 lerp,颜色用 lerp,甚至透明度也能 lerp。但旋转角度直接 lerp 就有大问题——后面会专门讲。
有了Keyframe和插值的概念,生成中间帧的框架就清晰了:解析两组关键帧里的所有属性,遍历t的所有取值(对应每一帧的时间点),分别插值每个属性,最后把插值结果渲染到画布上。
3. 核心插值算法的实现与原理
3.1 位置插值:向量线性插值的完整代码
位置插值是中间帧生成里最直观的部分。物体的坐标从一个点移动到另一个点,只要求出运动路径上的中间点即可。代码如下:
def interpolate_position(start: Vector, end: Vector, t: float) -> Vector: """物体位置从 start 均匀移动到 end。t 从 0 到 1,代表插值进度。""" return start.lerp(end, t)我们假设物体从 A 点(0, 0)移动到 B 点(100, 0),取t=0.3时,插值结果就是(0, 0) * 0.7 + (100, 0) * 0.3 = (30, 0)。这个‘30’的含义是:3 秒钟的动画,播放到第 0.9 秒时,物体应该出现在水平方向 30% 的位置上。实际渲染时我们把这个坐标作为物体的中心点,再叠加顶点偏移量,得到每个顶点在屏幕上的位置。
有一点要提醒:如果两个关键帧之间物体存在多段运动路径(比如先加速后减速),单纯的线性插值就不够了。这时需要把整段时间切成多个关键帧段,段内再用线性插值。关键帧多了以后,插值就变成了分段线性插值(piecewise linear interpolation),本质还是这一套代码,只是外层多了按时间寻找当前段的逻辑。
3.2 颜色插值:RGB 空间还是感知均匀空间?
颜色插值最常见的问题是:直接把 RGB 三个通道各自做线性插值,得到的效果不一定“看起来”平滑。因为 RGB 空间不是感知均匀的——人眼对亮度变化的敏感度远高于色相变化,红色通道从 0 到 255 的过程中,中间值 127 看起来并不像“一半的亮”。
课程实验阶段,直接做 RGB 线性插值完全够用。代码量很小:
def interpolate_color(c1: tuple, c2: tuple, t: float) -> tuple: """RGB 三通道分别插值,返回整数颜色元组。""" return tuple(round(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))举个具体例子:当前关键帧颜色(255, 0, 0)(纯红),下一关键帧(0, 0, 255)(纯蓝)。取t=0.5时,插值结果是(127, 0, 127)。从数字上看是合理的中点,但显示出来是暗紫色——“红色分量和蓝色分量都减半”并不等于“亮度减半”。
如果实验要求更专业的颜色过渡,可以切到 HSV 空间插值。HSV 把颜色拆成色相、饱和度、明度,色相本身是一个环,插值时需要走“最短角路径”(下面旋转插值那节会细说)。我们在最终代码里做了一版 HSV 插值作为备选,实际使用时看课题要求。单纯交作业的话,RGB 插值已经能说明问题。
3.3 旋转插值的坑:角度绕远路问题
旋转插值是整个实验里最容易翻车的地方。假设物体从 30 度旋转到 330 度,按照普通线性插值:
lerp(30, 330, t) = 30 + (330 - 30) * tt=0.5时得到 180 度。从视觉上看,物体顺时针旋转了 330-30=300 度到达终点。而实际上我们期待的是逆时针转 60 度到 -30 度(等价于 330 度)——角度值上差了 120 度。
问题的根源在于角度是周期性量,取值绕了一圈会回到原点。正确做法是先把角度差映射到[-π, π]区间,取整个圆上最短的那条弧:
def interpolate_rotation(start: float, end: float, t: float) -> float: """ 角度的线性插值,通过归一化角度差到 [-pi, pi],避免旋转绕远路。 """ delta = (end - start + np.pi) % (2 * np.pi) - np.pi return start + delta * t注意delta = (end - start + pi) % (2*pi) - pi这个操作:先把差值平移到正数区间,取模去掉多余整圈,再平移回以 pi 为中心的负区间。比如end - start = 300度时,加上 180 度后是 480 度,取模 360 得 120 度,再减 180 得 -60 度,正确。测试一下:30 度到 330 度的插值,现在t=0.5得到 0 度,物体从 30 度逆时针走了 30 度到 0 度——如果这时你预期的是顺时针大回转,反而会困惑。实际遵循的规则是“哪个方向距离短就走哪个方向”。
这条经验对课程作业尤其关键:很多老师会用“旋转路径是否最短”来检验你是否真的理解了角度插值,而不是背一个公式就把中间帧交上去。
3.4 顶点插值与形状渐变
形状插值相比位置、颜色插值稍微复杂,它处理的是物体自身形态的变化。对简单多边形来说,如果两个关键帧的顶点数相同,那么顶点的‘一一对应’是天然成立的,直接对每对对应坐标做线性插值即可。
def interpolate_vertices(start_verts: np.ndarray, end_verts: np.ndarray, t: float) -> np.ndarray: """ start_verts 和 end_verts 都是 Nx2 数组。 逐行插值,返回 Nx2 的新顶点数组。 """ return (1 - t) * start_verts + t * end_verts示例:起始三角形顶点[[0,0],[100,0],[50,100]],结束三角形顶点[[0,0],[100,0],[50,50]],t=0.5时中间帧顶点是[[0,0],[100,0],[50,75]]。第三顶点的 y 坐标从 100 平滑降到 50,整个三角形被“压扁”了一半。
顶点数不一致的情况更麻烦,比如同一边数的多边形消失或新增顶点,或者从四边形变到五边形。最简单的处理方案是在输入阶段就把顶点数统一:用重采样把顶点数较多一方的数量降到和另一方一致,或者对较少的做重复采样。这整个过程在动画术语里叫“对应性建立”(correspondence),在图形学文献里有大量方案,从 shape context 到 Procrustes 分析都有。课程实验里我建议使用“公共顶点数法”:取两个关键帧顶点数的最大值,少的那个做插值补出缺失顶点,之后再逐点插值。
4. 渲染管线与完整程序框架
4.1 从插值结果到可见画面:仿射变换和绘制顺序
有了每个帧所有属性的插值结果,接下来要解决的是把它们变成一张真实的图。这一步的渲染管线如下:
- 根据插值出的
scale和rotation,构建一个局部到屏幕的变换矩阵; - 对每个子形状的所有顶点应用变换矩阵,得到屏幕坐标;
- 用 Pillow 的
ImageDraw.polygon填充形状,用line画轮廓线; - 记录渲染后的帧,等全部生成完再合成视频。
旋转和缩放要写成矩阵形式:
def get_transform_matrix(rotation: float, scale: float, center: Vector): theta = rotation cos_t, sin_t = np.cos(theta), np.sin(theta) R = np.array([ [cos_t, -sin_t], [sin_t, cos_t] ]) S = np.eye(2) * scale M = R @ S return M这份实验的物体都是二维的,用 2x2 矩阵就够。顶点变换:
def apply_transform(vertices: np.ndarray, M: np.ndarray, offset: Vector) -> np.ndarray: """ vertices: Nx2 数组(局部坐标,原点为物体中心) M: 2x2 旋转缩放矩阵 offset: 平移量(插值后的位置坐标) """ return vertices @ M.T + np.array([offset.x, offset.y])这里用了@运算符做矩阵乘法。顶点矩阵是 Nx2,变换矩阵是 2x2,vertices @ M.T后仍是 Nx2,表示每个顶点都乘了同一个矩阵。偏移量用 broadcast 直接加到每一行上。
渲染顺序有讲究:多个子形状同时存在时,原则上先画后面的再画前面的(画家算法)。实验场景比较简单,一般按vertices_group的顺序渲染即可,不过如果你把中间帧生成器做成了通用模块,建议加入 z_index 字段控制层级。
4.2 生成帧的主循环代码
整个程序的主循环逻辑并不复杂,就是把所有帧遍历一遍,对每一帧计算插值参数t,然后按前面写好的各个插值函数取值,渲染,保存。完整的主循环如下:
import os from PIL import Image, ImageDraw def generate_frames(keyframe1: Keyframe, keyframe2: Keyframe, frame_rate: int = 30, output_dir: str = "frames"): """ 生成从 keyframe1 到 keyframe2 的所有中间帧,保存为 PNG 序列。 返回文件路径列表。 """ os.makedirs(output_dir, exist_ok=True) # 两个关键帧之间的时间差 duration = keyframe2.time - keyframe1.time # 总帧数 = 时间差 * 帧率,至少为 1 total_frames = max(1, int(round(duration * frame_rate))) canvas_size = (800, 600) file_paths = [] for idx in range(total_frames + 1): # 第 idx 帧对应的时间进度,范围 0 ~ 1 t = idx / total_frames if total_frames > 0 else 0.0 # 逐属性插值 pos = interpolate_position(keyframe1.position, keyframe2.position, t) rot = interpolate_rotation(keyframe1.rotation, keyframe2.rotation, t) scale = keyframe1.scale + (keyframe2.scale - keyframe1.scale) * t color = interpolate_color(keyframe1.color, keyframe2.color, t) # 对每个子形状做顶点插值 verts_group = [] for v1, v2 in zip(keyframe1.vertices_group, keyframe2.vertices_group): # 两个关键帧中形状数量和顶点数应一致,否则需要预处理对齐 verts = interpolate_vertices(v1, v2, t) verts_group.append(verts) # 变换和渲染 M = get_transform_matrix(rot, scale, pos) img = Image.new("RGB", canvas_size, (20, 20, 20)) draw = ImageDraw.Draw(img) for verts in verts_group: transformed = apply_transform(verts, M, pos) draw.polygon(transformed.flatten().tolist(), fill=color, outline=(255, 255, 255)) # 再画一条参考轴线,作为旋转变化的视觉参照 axis_start = apply_transform(np.array([[-80, 0]]), M, pos)[0] axis_end = apply_transform(np.array([[80, 0]]), M, pos)[0] draw.line([tuple(axis_start), tuple(axis_end)], fill=(255, 255, 0), width=2) frame_path = os.path.join(output_dir, f"frame_{idx:03d}.png") img.save(frame_path) file_paths.append(frame_path) return file_paths主循环里有个total_frames + 1的细节:当t=0时恢复第一帧,t=1时生成最后一个关键帧本身。这个“包含首尾”的设计让帧序列在视频连接时不会丢失端点。如果你的程序运行时报zip的参数长度不同,回头检查两个关键帧的vertices_group的子形状数量是否一致。
4.3 用 Pillow 将 PNG 序列合成为 GIF
调试阶段逐张翻看静态图也能发现肉眼可见的跳变,但想真正检验动画流畅度,还是要合成动图。Pillow 自带 GIF 保存功能:
from PIL import Image def make_gif(frame_paths: list, output_path: str = "animation.gif", duration: int = 33): """ 将 PNG 帧序列合成 GIF。 duration 参数是每帧显示时长(毫秒), 30fps 对应约 33ms,24fps 对应约 42ms。 """ frames = [Image.open(path).convert("RGB") for path in frame_paths] # GIF 格式某些环境下需要 P 模式以减小体积 frames[0].save( output_path, save_all=True, append_images=frames[1:], duration=duration, loop=0 )就是这么简单。loop=0表示无限循环播放,看效果方便。如果你对 GIF 的 256 色调色板不满意,可以改用 OpenCV 输出 MP4:
import cv2 import numpy as np def make_mp4(frame_paths: list, output_path: str = "animation.mp4", fps: int = 30): first = cv2.imread(frame_paths[0]) h, w, _ = first.shape writer = cv2.VideoWriter(output_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h)) for path in frame_paths: frame = cv2.imread(path) writer.write(frame) writer.release()我自己的经验是:调算法阶段用 GIF,颜色不准确但胜在省事;实验报告里的动图演示也用 GIF 方便插入到文档;需要高保真输出再走 MP4。
4.4 贝塞尔曲线路径插值让运动更自然
线性位置插值生成的动画有一个典型特征:物体全程匀速运动,看起来机械、生硬。真实世界中的物体运动几乎总是伴随着加速和减速——小球从手边脱落是加速,汽车进站是减速。想让中间帧动画看起来自然,需要用缓动函数(easing function)替代简单的线性映射。
缓动的核心思想:对时间进度t再做一个非线性映射,然后再用映射后的值去插值。比如先慢后快的 ease-in:
def ease_in_quad(t: float) -> float: return t * t def ease_out_quad(t: float) -> float: return 1 - (1 - t) * (1 - t)实际使用的姿势是:
t_eased = ease_out_quad(t) # 先对时间做非线性变换 pos = start_pos.lerp(end_pos, t_eased) # 再按非线性后的进度插值用ease_out_quad时,物体在前期移动慢、后期移动快,给人一种“逐渐加速”的感觉;ease_in_quad相反。以上两种都是改变整个运动的“宏观节奏”,但物体在运动中除了位移还有路径形状。直线移动并不是唯一选择,弧形路径往往更自然。如果想让物体的轨迹变成一条虚拟的贝塞尔曲线——比如抛物线——就不能再对位置做直接线性插值,而要对控制点做插值,再把贝塞尔公式展开:
def quadratic_bezier(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray, t: float) -> np.ndarray: """三控制点的二次贝塞尔曲线求值,t 是曲线参数。""" return (1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t ** 2 * p2这里的p1是控制点,决定曲线的弯曲方向。动画路径设计里这是常用技巧:把位置从“线性插值”换成“二次贝塞尔插值”,中间的p1手动指定,物体就能画出弧线运动。
5. 中间帧抖动问题和进阶优化方向
5.1 抖动排查:从视觉现象到根因的完整链路
把程序跑通之后,最大的敌人是中间帧抖动——动画播放时物体不是顺滑移动,而是忽忽悠悠地跳。我这里分享一下排查这个问题的完整思路链条,远比直接修改代码重要。
第一个会遇见的现象:物体宽度不停变化。排查过程如下:
- 怀疑缩放插值:我在
generate_frames里对scale做了线性插值,但如果两个关键帧的scale值本身不同(比如 1.0 和 1.2),中间帧的物体尺寸自然不同。如果把scale改为始终 1.0,问题还存在,就排除了缩放。 - 怀疑旋转插值:旋转中心的不同会造成视觉上的横跳。
- 继续看变换矩阵:注意
get_transform_matrix里的S = np.eye(2) * scale,如果scale在这一帧是 1.0,下一帧是 1.2,矩阵直接把顶点坐标拉伸了。视觉表现是物体在一整帧内的所有点同时被“拉宽”,但仔细看会发现这不是位置抖动,而是尺寸缩放。
第二个常遇到的现象:物体转角处出现莫名其妙的尖刺或重叠。这类问题的根因几乎都是顶点插值时的顶点顺序不一致。比如第一个关键帧的三边形顶点按顺时针排列[[0,0],[100,0],[50,100]],第二个关键帧的三角形顶点可能写成了[[100,0],[50,100],[0,0]],顺序逆时针,导致插值出来的每对顶点在几何上对应错位,中间帧的边会从正确位置扭曲到错误位置。
解决办法是在插值前统一顶点绕序:
def fix_winding_order(vertices: np.ndarray) -> np.ndarray: """按极角排序,确保顶点顺时针或逆时针排列一致。""" cx, cy = vertices.mean(axis=0) angles = np.arctan2(vertices[:, 1] - cy, vertices[:, 0] - cx) sorted_idx = np.argsort(angles) # 升序为逆时针 return vertices[sorted_idx]但这里还有一个微妙的点:如果两个关键帧的“第一顶点”不是同一个位置,即使绕序一致,对应关系也可能不对。例如三角形 A 的第一个顶点在左上角,三角形 B 的第一个顶点在右下角,插值出来的中间帧会出现“顶点漂移”现象——顶点在运动初期快速绕一圈到达对应位置,看起来像形状在旋转。为了让形状渐变平稳,还要在插值前对齐第一个顶点:
def align_first_vertex(verts: np.ndarray, ref_verts: np.ndarray) -> np.ndarray: """把 verts 旋转平移,使第一个顶点与 ref_verts 的第一个顶点对齐。""" shift = ref_verts[0] - verts[0] return verts + shift第三个抖动现象:物体颜色在播放时闪烁。排查顺序是:先确认是颜色闪烁还是整体亮度变化。RGB 整数溢出是我遇到最多的情况。插值结果round(...)后可能超过 255 或者小于 0,Pillow 的fill收到非法颜色值会静默丢弃或产生错位。最稳妥的做法是在颜色插值里夹紧:
def clamp_color(c: tuple) -> tuple: return tuple(max(0, min(255, int(round(v)))) for v in c)如果在排查过程中发现颜色没问题,那就是渲染时调色板的问题(比如 GIF 的 256 色调色板在帧间变化太大导致视觉闪烁)。
第四个现象是“突然的跳动”:物体运行到中间某个位置时瞬间跳到另一个位置,随后又恢复平滑。这个问题几乎总是因为关键帧的时间戳设置不均匀。比如第一个关键帧在 0 秒,第二个在 1 秒,第三个在 1.1 秒——第三和第四个关键帧之间只有 0.1 秒,对应帧数极少,视觉上会产生一次“快进”的感觉。排查方式是打印所有关键帧时间和对应的t值,看是否有明显跳变。
5.2 中间帧数量的自适应控制与运行性能优化
控制中间帧数量看起来简单——total_frames = duration * frame_rate——但实际上有挺多讲究,尤其在两个关键帧距离很远的情况下。
先明确一个事实:帧率决定每秒的帧数,但插值参数t只是一个介于 0 和 1 之间的比例。假设物体从[0,0]移动到[500, 500],帧率为 30fps,动画时长 2 秒,那么共有 60 帧,每一帧位置变化约500/60 ≈ 8.33像素。这在视觉上已经足够平滑。但如果物体移动距离只有 5 像素,你仍然生成 60 帧,每帧的位置变化不到 0.1 像素,这种过度采样会浪费计算资源和存储空间。
自适应帧数的一个合理策略是:根据起点到终点的总欧氏距离设定一个每帧最大移动距离(比如 2 像素),然后反推需要的帧数:
def adaptive_frame_count(start_pos: Vector, end_pos: Vector, frame_rate: int, max_pixels_per_frame: float = 2.0) -> int: distance = np.linalg.norm([end_pos.x - start_pos.x, end_pos.y - start_pos.y]) min_frames_by_distance = int(distance / max_pixels_per_frame) duration_frames = max(1, int(round(duration * frame_rate))) return max(min_frames_by_distance, duration_frames)性能方面,顶点数量少时(两位数),纯 Python 循环问题不大。但当顶点数达到几千个,比如用多个贝塞尔控制点近似一个复杂形状时,逐点插值就会明显变慢。这时候要善用 NumPy 的向量化执行——前面代码里(1 - t) * start_verts + t * end_verts就把循环掩盖在数组运算之后了。另一个性能瓶颈在矩阵拼接:get_transform_matrix里的np.array每次创建新数组,大量调用时也会有小开销。如果是渲染几百帧的小项目,这些都不是问题;做大规模项目时可以考虑把矩阵整体预计算,而非逐帧调用函数。
5.3 形状渐变中的顶点对应关系处理
前面提到顶点数不一致时需要预处理对齐,这里展开细讲。假设第一个关键帧是一个四边形,第二个是一个五边形,直接跑interpolate_vertices会报维度错误。解决办法是“重新采样”:给顶点数量少的那个关键帧插入冗余顶点,使得两边数量相等。
最常见的做法是均匀重采样法。先按顶点顺序把多边形映射成一条参数曲线上的点列,然后强制把这条曲线均匀切分为目标数量的段,得到新的顶点序列。实现起来比较简单:
def resample_polygon(vertices: np.ndarray, m: int) -> np.ndarray: """ 将 vertices 描述的多边形重采样为 m 个顶点。 按周长等距离取点,返回 m 个等距顶点。 """ n = len(vertices) # 计算每条边的累积长度,把多边形展开成参数化线段 if n == 0: return np.zeros((m, 2)) edges = np.diff(np.vstack([vertices, vertices[0]]), axis=0) lengths = np.linalg.norm(edges, axis=1) cum_len = np.concatenate([[0], np.cumsum(lengths)]) total_len = cum_len[-1] if total_len == 0: return np.zeros((m, 2)) resampled = [] for i in range(m): target_len = i * total_len / m # 找到 target_len 所在的边 seg_idx = np.searchsorted(cum_len, target_len) - 1 seg_idx = max(0, seg_idx) t_local = (target_len - cum_len[seg_idx]) / max(lengths[seg_idx], 1e-8) pt = vertices[seg_idx] + t_local * edges[seg_idx] resampled.append(pt) return np.array(resampled)这个函数修正了“顶点数不一致就做不了插值”的误区。你先对两个多边形都做重采样到统一数量(比如取 max(n1, n2)),然后就能用标准的逐顶点插值了。实际运行时你会发现,五边形到四边形之间的渐变过程可能会让中间多出来的那个顶点在某一段时间里“飘”在形状中间,在视觉上形成一个“额外尖角”。这是顶点对应关系不自然的直接后果,在课程实验的可接受范围内,但如果想追求更高质量的形状渐变,就需要做顶点对应优化——这已经是学术论文级别的问题,不做展开。
5.4 透明度和材质属性的插值扩展
实验要求通常只涉及位置、颜色、旋转、缩放,但中间帧系统如果要做到实用,必须把更多属性纳入插值范围。透明度(alpha)是很容易加的一个:
def interpolate_alpha(a1: float, a2: float, t: float) -> float: return a1 + (a2 - a1) * t然后在渲染时,把 alpha 值传给 Pillow 的蒙版或直接写死半透明颜色。Pillow 画多边形不支持直接填半透明色,需要用一张带 RGBA 模式的画布:
from PIL import Image, ImageDraw def draw_with_alpha(polygon: list, color: tuple, alpha: float, canvas_size: tuple = (800, 600)): """设置 RGBA 模式画布,实现半透明形状叠加。""" img = Image.new("RGBA", canvas_size, (20, 20, 20, 255)) alpha_int = int(alpha * 255) fill_color = (*color, alpha_int) draw = ImageDraw.Draw(img) draw.polygon(polygon, fill=fill_color, outline=None) return img这样做之后,物体淡出淡入的动画就很容易实现——只需把透明度的起始值设为 255、结束设为 0。材质属性(比如形状的边缘宽度、图案密度)也可以同理纳入,不过这些字段需要你自己在 Keyframe 类里先定义好。
6. 完整实验过程回顾与调参经验
6.1 从三角形到转动的圆形:一个完整的演示例程
理论讲了一大堆,这里放一个能直接跑通的最小示例。目标是从第 0 秒的一个三角形,过渡到第 2 秒的一个圆形(用 24 个顶点的多边形近似),同时位置从左上角移动到右下角,颜色从红色变成蓝色,旋转从 0 度转到 180 度。
代码完整段如下:
import numpy as np from generator import generate_frames # 假设前面的代码都装在一个模块里 from keyframe import Keyframe, Vector def make_circle_vertices(center=(0, 0), radius=50, num_segments=24): angles = np.linspace(0, 2 * np.pi, num_segments, endpoint=False) pts = np.stack([ center[0] + radius * np.cos(angles), center[1] + radius * np.sin(angles) ], axis=1) return pts def main(): # 第一关键帧:三角形 tri = np.array([[0, -40], [35, 30], [-35, 30]], dtype=float) kf0 = Keyframe( time=0.0, position=Vector(-200, 150), rotation=0.0, scale=1.0, color=(255, 0, 0), vertices_group=[tri] ) # 第二关键帧:圆形 circle = make_circle_vertices(radius=45, num_segments=24) kf1 = Keyframe( time=2.0, position=Vector(200, -150), rotation=np.pi, scale=1.2, color=(0, 0, 255), vertices_group=[circle] ) # 生成中间帧序列 files = generate_frames(kf0, kf1, frame_rate=30, output_dir="demo_frames") # 合成 GIF from render import make_gif make_gif(files, "demo.gif", duration=33) print("生成完成,共", len(files), "帧") if __name__ == "__main__": main()注意三角形只有 3 个顶点,圆形有 24 个,直接调generate_frames必报维度错误。所以需要预处理——把三角形重采样成 24 个顶点:
from resample import resample_polygon tri_resampled = resample_polygon(tri, 24)这样最终两个关键帧的vertices_group都是[24x2]的数组,插值逻辑才能跑通。这个预处理步骤千万别漏。
6.2 实验中的视觉效果判断标准
实验做完,怎么判断中间帧生成得“好不好”?我自己的标准按优先级是:
- 形状是否整体平滑变化,有没有明显的顶点穿插;
- 旋转路径是否走了最短弧线;
- 颜色过渡是否自然,有没有突然的跳变色;
- 物体运动速度是否有“机械匀速感”——有线性运动很正常,但如果你做了缓动函数,应该能明显看出加减速;
- 帧序列作为视频播放时是否稳定,不闪烁不抖动。
判断工具其实就是肉眼 + 逐帧对比。一种很有效的调试手段是把相邻帧叠加显示,看物体轮廓在相邻帧间是否发生大位移。如果在某个t附近的位移明显大于前后帧,那这个位置就是问题点。
6.3 一次典型错误:矩阵乘法顺序错了
最后分享一个非常隐蔽但常见的错误——矩阵乘法的顺序。很多初学者在实现旋转缩放时写成了:
M = S @ R # 错误:先缩放再旋转??从直觉上好像先缩放再旋转没毛病,但注意我们的顶点是“局部坐标乘以矩阵”:
transformed = vertices @ M.T根据矩阵乘法的结合律,如果局部坐标是一个行向量,那么vertices @ (R @ S).T等价于(vertices @ S.T) @ R.T,实际上是先缩放后旋转。这里的陷阱在于@ M.T改变了交换顺序的直观含义。我的建议是:先在纸上写出数学表达式,再用代码一步步验证,最后再写注释标记“先缩放再旋转”。
正确做法统一为:先旋转再缩放(对于同心几何,旋转和缩放顺序不影响结果;对于非同心几何,顺序才会影响结果)。
7. 中间帧技术在更广场景下的应用参考
中间帧生成技术不做成课程实验后,其实一直在各类工具里默默发挥价值。网页动画里 CSS transition 的cubic-bezier就是缓动曲线对时间参数的调整;游戏引擎里的动画曲线编辑器(如 Unity 的 Animation Curves)本质上就是对关键帧属性做分段插值;视频编辑软件的“光流法”则尝试在无法获取几何信息的情况下自动像素插值。图形学课程把中间帧生成作为实验题目,是为了让你理解一切动画系统的共同起点:插值。
这个实验如果还想延伸,推荐两条路线。一条是三维方向:把二维位置的插值扩成Vector3,再加上欧拉角或四元数旋转插值(注意四元数的 spherical linear interpolation(slerp)比欧拉角更能避免万向锁问题)。另一条是自动对应方向:输入两张没有对应关系的轮廓图像,自动提取关键点,再智能匹配生成中间帧,这是计算机动画领域真正有价值的研究方向,要做的东西比课程实验复杂太多。
实际做项目时,我的习惯是在中间帧生成器里保留一个“插值模式”参数(linear/ease_in/ease_out/bezier)。原因很简单:不同物体在同一个动画里的运动规律不一样——背景云的移动用线性就够了,角色跳跃需要抛物线,UI 弹窗出现用 ease_out。课程作业里把这三个模式都实现一遍并对比效果,也更容易拿到高分。