· 6 分钟阅读 · 2111 字
动画贝塞尔缓动:横向手柄为什么没有改变播放节奏
把动画曲线的两个横向手柄都向右拖,预期是物体在前半程停留更久。如果运行时仍然在时间过半时走完一半距离,问题可能出在求值入口:代码直接计算了 y(s),其中 s 是归一化时间。这样无论怎样修改横向手柄,输出都不会变化,因为两个横坐标根本没有进入计算。

独立 C# 动画求值探针固定四个控制点 P0=(0,0)、P1=(0.8,0)、P2=(0.9,1)、P3=(1,1)。时间进度 s=0.5 对应的曲线参数为 t=0.2639177702,输出进度为 0.1721926561。若把参数直接设为 0.5,读出的进度是 0.5,但这个曲线点的横坐标已经到了 0.7625。它回答的是曲线参数走到一半时在哪里,动画需要的却是时间走到一半时在哪里。
时间轴需要一次反解
三次贝塞尔的单轴表达式为:
B(t; a,b) = 3(1-t)²ta + 3(1-t)t²b + t³
x(t) = B(t; x1,x2)
y(t) = B(t; y1,y2)
输出 E(s) = y(t),其中 x(t)=s
这里的 t 是构造曲线的无量纲参数,s 是动画时钟给出的无量纲时间比例。只有 x(t)=t 时才能直接代入相同数值。手柄在对角线上也不代表参数线性:例如控制点 (0,0)、(1,1) 形成的几何轨迹是对角线,但 x(t)=3t²-2t³。这条曲线的最终缓动函数应是恒等映射,因为反解后 y(t)=x(t)=s。
当前合同固定端点,要求 x1,x2 都在 [0,1]。在这个范围内,横坐标单调不减且整体严格递增,内部可以有孤立的零导数点;因此每个时间输入都有唯一参数。横向手柄不必满足 x1≤x2,(1,0) 这组横坐标也合法。若允许横向手柄任意越界,曲线可能回折,同一时间出现多个交点,求值器还要决定选择哪一支。这里选择在数据入口拒绝这种定义,不把分支选择隐含在数值迭代里。
核心位于 .tmp/daily-labs/2026-10-30-2000/BezierEase.cs,调用链是 Program.Check → Evaluate → Coordinate。Evaluate 返回求得的参数与进度,方便测试反解残差;表现层只消费进度:
public static class BezierEase
{
public static double Coordinate(double t, double a, double b)
{
double u = 1 - t;
return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t;
}
public static (double Parameter, double Value) Evaluate(
double time, double x1, double y1, double x2, double y2)
{
if (!double.IsFinite(time) || time < 0 || time > 1 ||
!double.IsFinite(x1) || !double.IsFinite(x2) ||
x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1 ||
!double.IsFinite(y1) || !double.IsFinite(y2) ||
y1 < -4 || y1 > 4 || y2 < -4 || y2 > 4)
throw new ArgumentOutOfRangeException(nameof(time));
if (time == 0 || time == 1) return (time, time);
// 固定二分次数,以参数误差为合同,避免平坦导数下牛顿步越界。
double lo = 0, hi = 1;
for (int i = 0; i < 48; i++)
{
double mid = (lo + hi) * 0.5;
if (Coordinate(mid, x1, x2) < time) lo = mid;
else hi = mid;
}
double t = (lo + hi) * 0.5;
return (t, Coordinate(t, y1, y2));
}
}
纵向控制点允许 [-4,4],这是本探针的显式数值合同,保留有限超调空间。它没有把输出钳制到 [0,1]:控制点 (0.2,2)、(0.8,2) 在半程给出约 1.625,适合有意越过目标的位移效果。透明度等不能超调的属性应在应用层明确处理,而不是让通用求值器偷偷改变曲线。时间超界同样由入口拒绝;循环、倒播或结束停留属于时钟层的归一化规则。

平坦导数会放大反解误差
采用二分法,是为了始终保留包含交点的参数区间。每轮比较 x(mid) 与目标时间,并丢弃另一半;48 轮在精确算术下把初始参数区间压缩到 2^-48。代码不会在小导数附近做除法,也不会把候选点推出 [0,1]。这个选择让当前实现容易核验,但浮点数上的误差仍需单独讨论。
以 x1=x2=0 为例,x(t)=t³。时间 s=10^-9 的正确参数是 0.001。若牛顿法直接用 t=s 初始化,导数为 3×10^-18,第一步就跳到约 333333333.3333334。本章边界回归得到二分参数 0.0009999999999994458,输出进度约 2.998×10^-6。运行时若选择牛顿加速,需要保留括区并在导数过小、候选越界时退回二分;不能只把迭代次数调大。
另一个更隐蔽的边界是横向控制点 (1,0)。此时 x(t)=0.5+4(t-0.5)³,中心导数为零。固定 s=0.5,解析参数是 0.5;当前 double 实现返回的进度为 0.4999968569895321,与解析值相差约 3.143×10^-6。接近中心时,多个不同参数的横坐标会舍入到相同浮点值,比较无法再可靠地区分它们。48 轮区间宽度不能直接变成对真实根的 48 位精度保证。
这也是只检查 |x(t)-s| 不够的原因。输出灵敏度取决于 dy/dx,横轴接近平坦时,很小的时间残差仍能对应可见的输出误差。若位移幅度为 100 个单位,上述样本的位移偏差约为 0.0003143 个单位;扩大到很长的路径,或者把曲线用于严格判定,误差预算便可能不再成立。需要更高精度时,应为退化曲线采用稳定表达式或专门求根策略,并验证最终属性误差,而不是继续缩小已经失去分辨率的时间残差。
求值精度不能决定玩法时序
Program.cs 的回归先随机生成已知参数 t,正向计算 s=x(t),再反解并与原参数、原纵坐标比较。固定种子 200030,10,000 组横向控制点位于 [0,1)、纵向控制点位于 [-2,2),参数位于 [0.001,0.999)。最大参数误差为 4.7739590×10^-15,最大输出误差为 2.8754776×10^-14,最大横向残差为 4.7739590×10^-15。随机结果与中心退化样本分别记录,避免大批普通样本掩盖极小概率的病态输入。
决定性的失败回归保留为:
var sample = BezierEase.Evaluate(0.5, 0.8, 0, 0.9, 1);
double wrong = BezierEase.Coordinate(0.5, 0, 1);
Check(Math.Abs(BezierEase.Coordinate(sample.Parameter, 0.8, 0.9) - 0.5)
< 1e-13, "time inversion");
Check(sample.Value < 0.2 && wrong == 0.5, "wrong parameter regression");
当天执行命令:
dotnet build $WORK_DIR/BezierEaseLab.csproj -c Release
dotnet run --project $WORK_DIR/BezierEaseLab.csproj -c Release --no-build
Release 构建退出码 0,零警告、零错误,耗时 5.75 s;运行退出码 0,24 项检查通过、失败 0、跳过 0,探针内部耗时 88.5105 ms。覆盖起终点、恒等曲线、横向手柄变化、允许的手柄逆序、超调、两类平坦导数、非法输入和 10,000 组正反向回归。这些耗时是本次执行记录,不用于比较求值性能;探针尚未接入引擎播放或画面捕获。
求值器应位于时钟与属性写入之间:时钟提供归一化时间,求值器执行横轴反解,表现层按输出写入位置、缩放等属性。伤害生效、状态完成和循环次数继续依据逻辑时钟;它们不能等待视觉进度刚好等于 1,因为曲线可能超调,也可能在浮点计算中提前靠近终点。
对美术工具链,最有价值的验收是固定时间采样,而非只比较四个控制点是否导出一致。离线烘焙查找表时,也必须按等时间 s 采样 E(s);按等参数 t 烘焙,再把数组下标当时间,会把同一个错误换成缓存形式。当前 48 轮求值可作为可核验基线,是否改成带括区牛顿法或查找表,应由目标属性的误差上限与实际调用成本决定。横向手柄只有进入这条反解链,才真正拥有动画节奏的控制权。