
贝塞尔曲线上的匀速运动uniform speed on a bezier curve这件事我前前后后至少重新学了三遍。每次推导完都觉得终于懂了过两个月再看又得从头翻资料因为网上答案大多数只给数学公式不给能落地的工程方案。这篇记录的目标很明确看完你不仅能理解为什么“参数等距”不等于“路程等距”还能拿到可以直接复用的查表实现和二分换算代码。适合做动画编辑器、路径动画、游戏里平滑移动、机器人轨迹规划以及任何需要让物体沿曲线稳定行走的开发者。1. 为什么贝塞尔曲线上的“参数均匀”不等于“速度均匀”1.1 第一次看资料时我理解错在哪大多数人第一次接触贝塞尔曲线拿到的定义长这样B(t) (1-t)^3 * P0 3*(1-t)^2*t * P1 3*(1-t)*t^2 * P2 t^3 * P3这里的t从 0 到 1很自然就会产生一个直觉只要让t dt物体就会沿着曲线均匀移动。这个直觉在直线段上是对的但在贝塞尔曲线上是错的。贝塞尔曲线本质上是一种“参数曲线”t只是参数不代表“曲线走了多长的路”。就像一辆车方向盘转的角度每小时都在变但你不能说方向盘转了 180 度车就匀速移动了 180 度。车速由发动机转速和变速箱共同决定而曲线的“局部速度”则由控制点的几何关系决定。我举个例子一条三次贝塞尔曲线P0 (0, 0) P1 (0, 100) P2 (100, 100) P3 (100, 0)它的形状像一个拉长的 S 形。如果直接让t每帧增加 0.01你会发现物体在靠近两端的时候跑得快在中间转弯处明显变得慢。原因很简单在某一小段上Δt对应的实际曲线长度并不是固定的。1.2 把“速度”写出来问题就清楚了要理解为什么速度不均匀先得对曲线求导。三次贝塞尔的导函数是C(t) 3*(1-t)^2*(P1-P0) 6*(1-t)*t*(P2-P1) 3*t^2*(P3-P2)这个公式是从 Bernstein 基函数的导数推导出来的在代码里可以直接用向量运算算出来。曲线在参数t处的“瞬时速度大小”就是v(t) |C(t)| sqrt(C.x^2 C.y^2)在平面里|C(t)|就是切向量的长度。对上面那个控制点例子可以快速算一下t0时C(t) 3*(P1-P0) (0, 300)速度大小是 300t0.5时代进去算得到C(0.5) (150, 0)速度大小只有 150t1时C(t) 3*(P3-P2) (0, -300)速度大小又变成 300同样的dt 0.01在起点处移动的实际距离大约是300 * 0.01 3个单位在中间只有150 * 0.01 1.5个单位。整整差了一倍。如果你的控制点更夸张比如P1离P0很远、P2离P3很远速度差异可以到 3 倍、5 倍甚至更高。所以想让物体沿曲线匀速运动核心问题不是“怎么调t”而是“怎么把动画时间映射到真正均匀的弧长上”。这个映射关系就是弧长参数化。2. 弧长参数化匀速运动的数学本质2.1 弧长公式与反函数要均匀移动我们需要一个新的参数s它表示从曲线起点开始走过的实际曲线长度也就是弧长。给定t弧长的定义是s(t) ∫[0, t] |C(u)| du整个曲线从起点到终点的总长度是L s(1)如果动画时间归一化成f0 到 1那么匀速运动要求物体在时刻f恰好走完总长度的f倍也就是目标距离d L * f问题就变成了已知目标弧长d求一个t使得s(t) d。这就是弧长函数的反函数t s⁻¹(d)到这里问题已经清晰了一半只要能得到s(t)并且能对它求反函数匀速运动就解决了。2.2 为什么没有解析解别死磕公式如果你去搜“贝塞尔曲线弧长解析解”大概率会看到一堆让你头疼的数学。原因说出来其实不复杂s(t) ∫[0, t] sqrt( a*u^4 b*u^3 c*u^2 d*u e ) du对三次贝塞尔曲线来说C(t)的两个分量最高次数是 2平方之后最高次数是 4再开根号。这个积分通常没有初等函数的解析表达式只能走数值方法。二次贝塞尔稍微好一点但也只是能把积分式子写成椭圆积分工程上一样没人天天手算椭圆积分。所以我的建议很明确除非你的曲线是直线、圆弧这类有特殊性质的特例否则别在解析解上死磕。在真实项目里大家普遍用三种工程方案数值积分加二分查找、查表插值、实时步长校正。下面逐个拆开讲。3. 三种工程落点方案按需选3.1 方案一数值积分 二分查找最直接的做法是用辛普森法则或高斯积分计算s(t)给定目标距离d在t ∈ [0, 1]上做二分查找每次都算s(mid)判断是偏大还是偏小伪代码思路target d lo 0 hi 1 repeat 10 times: mid (lo hi) / 2 if s(mid) target: lo mid else: hi mid t (lo hi) / 2这个方案精度很高缺点是每次查询都要做一次数值积分。如果只需要取一个点没问题如果要做动画每帧好几条曲线同时取点开销就上来了。我一般只在离线烘焙轨迹时用这个方案比如把一段运动簸箕成几十个关键帧之后播放时直接读烘焙好的数据。3.2 方案二查表 线性插值这是我最推荐的方案。思路是提前把曲线按参数均匀切成N段用数值积分算出每段长度得到一张累计长度表。查询时先找到目标距离落在哪个区间再做线性插值。伪代码table [{ t: 0, cum: 0 }] for i 1 to N: t i / N len simpson(a (i-1)/N, b t) cum len table.push({ t, cum }) query(d): i findSegment(table, d) ratio (d - table[i].cum) / (table[i1].cum - table[i].cum) t table[i].t ratio * (table[i1].t - table[i].t)为什么首选它因为一次建表之后每次查询都只要一次二分查找加一次线性插值成本非常低。而且累计长度表本身就是单调递增的不容易踩坑。如果觉得精度不够可以把N从 100 提到 500或者最后再加一两次牛顿迭代精修。对绝大多数 UI、游戏场景N 100已经能让屏幕上的位置误差小于 1 个像素了。3.3 方案三实时步长校正这个方案不是按弧长统一规划而是边走边算speed |C(t)| // 当前速度 t step / speed // step 是这一帧希望走过的距离它本质上是用欧拉法解一个微分方程。只要step足够小效果也还行而且不需要建表。缺点有两个如果speed很小step / speed会非常大一步可能冲过整条曲线欧拉法会积累误差走完一圈后终点偏了位置和理论值对不上我一般把step / max(speed, epsilon)作为处理方式epsilon取1e-6或更大一点。这个方案适合原型验证、性能极端受限的场景但如果你要做一个“严格匀速”的路径动画不建议作为主方案。三个方案的核心差异可以看这张表方案精度每次查询成本实现难度适合场景数值积分 二分很高高中离线烘焙轨迹、高精度路径预览查表 线性插值高取决于采样数低低动画编辑器、游戏路径、UI 动效实时步长校正中等长时间会漂移最低最低原型验证、简单跟随、性能极紧4. 我完整跑通过的查表实现附代码和参数4.1 工具函数点、导数、长度、建表下面这套 TypeScript 代码是我在编辑器项目里跑过很久的版本。没有太多花活追求的是稳定和可读性。输入四个控制点就能得到一个可查询的弧长表。export interface Vec2 { x: number; y: number; } export function cubicPoint(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { const mt 1 - t; const a mt * mt * mt; const b 3 * mt * mt * t; const c 3 * mt * t * t; const d t * t * t; return { x: a * p0.x b * p1.x c * p2.x d * p3.x, y: a * p0.y b * p1.y c * p2.y d * p3.y, }; } export function cubicDerivative(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { const mt 1 - t; const a 3 * mt * mt; const b 6 * mt * t; const c 3 * t * t; return { x: a * (p1.x - p0.x) b * (p2.x - p1.x) c * (p3.x - p2.x), y: a * (p1.y - p0.y) b * (p2.y - p1.y) c * (p3.y - p2.y), }; } export function speedAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): number { const d cubicDerivative(p0, p1, p2, p3, t); return Math.hypot(d.x, d.y); } function simpsonLength( p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, a: number, b: number, n 16 ): number { const h (b - a) / n; let sum speedAt(p0, p1, p2, p3, a) speedAt(p0, p1, p2, p3, b); for (let i 1; i n; i) { const t a i * h; sum speedAt(p0, p1, p2, p3, t) * (i % 2 0 ? 2 : 4); } return (sum * h) / 3; } export type LengthTableEntry { t: number; cum: number; }; export function buildLengthTable( p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, segments 100 ): LengthTableEntry[] { const table: LengthTableEntry[] [{ t: 0, cum: 0 }]; let cum 0; for (let i 1; i segments; i) { const t i / segments; const len simpsonLength(p0, p1, p2, p3, (i - 1) / segments, t, 16); cum len; table.push({ t, cum }); } return table; }这里有几个关键点值得说明simpsonLength把每个小区间再切 16 份做辛普森积分精度足够高segments 100时最后累计长度的相对误差通常在1e-4量级导数公式直接通过控制点差值算不需要再对点公式求符号导数省一多半出错概率Math.hypot比手动Math.sqrt(x*x y*y)更稳能避免极大或极小数值下的精度问题4.2 根据距离换算参数 t表建好了接下来要做两件事二分查找目标距离落在哪个区间然后线性插值出t。export function findSegment(table: LengthTableEntry[], distance: number): number { const n table.length; if (distance table[0].cum) return 0; if (distance table[n - 1].cum) return n - 2; let lo 0; let hi n - 1; while (hi - lo 1) { const mid (lo hi) 1; if (table[mid].cum distance) { lo mid; } else { hi mid; } } return lo; } export function tFromDistance( table: LengthTableEntry[], distance: number ): number { const i findSegment(table, distance); const a table[i]; const b table[i 1]; const segDist b.cum - a.cum; if (segDist 1e-9) { return a.t; } const ratio (distance - a.cum) / segDist; return a.t (b.t - a.t) * ratio; }使用方式很简单const P0 { x: 0, y: 0 }; const P1 { x: 0, y: 100 }; const P2 { x: 100, y: 100 }; const P3 { x: 100, y: 0 }; const table buildLengthTable(P0, P1, P2, P3, 200); const totalLength table[table.length - 1].cum; // 动画进度 fraction 从 0 到 1 function pointAtFraction(fraction: number): Vec2 { const distance totalLength * fraction; const t tFromDistance(table, distance); return cubicPoint(P0, P1, P2, P3, t); }如果你还想要更高的精度可以在线性插值之后做两三次牛顿迭代。核心思路是当前t对应的实际弧长和目标距离之间的误差除以当前速度作为修正量。function refineT( p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, table: LengthTableEntry[], distance: number, iterations 3 ): number { let t tFromDistance(table, distance); for (let i 0; i iterations; i) { const s arcLengthTo(p0, p1, p2, p3, table, t); const err distance - s; const v speedAt(p0, p1, p2, p3, t); if (Math.abs(err) 1e-4 || v 1e-8) break; t err / v; t Math.max(0, Math.min(1, t)); } return t; }arcLengthTo可以用表里的前缀累计值再加末端小区间的辛普森积分实现思路和buildLengthTable一样这里就不展开了。实际项目中如果segments 200且控制点坐标在几千像素以内线性插值已经足够连精修都可以省掉。4.3 多段贝塞尔路径的时间分配很多人的曲线不是一个三次贝塞尔而是好几个贝塞尔首尾相接组成的路径。这时最容易犯的错误是对每段都从 0 到 1 分配相同的时间。这样做会让每一段都有相同时长但每一段的几何长度往往不一样。结果就是长的路径走得快短的路径走得慢整体完全不是匀速。正确做法是先算出每一段的总长度再做全局累计export interface CubicCurve { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2; } export interface MultiPath { curves: CubicCurve[]; tables: LengthTableEntry[][]; cumulativeSegStart: number[]; total: number; } export function buildMultiPath(path: CubicCurve[]): MultiPath { const tables path.map((c) buildLengthTable(c.p0, c.p1, c.p2, c.p3, 100)); const cumulativeSegStart [0]; let total 0; for (let i 0; i path.length; i) { const table tables[i]; total table[table.length - 1].cum; cumulativeSegStart.push(total); } return { curves: path, tables, cumulativeSegStart, total, }; } export function multiPointAtDistance(path: MultiPath, distance: number): Vec2 { const d Math.max(0, Math.min(path.total, distance)); let idx 0; while (idx path.curves.length - 1 path.cumulativeSegStart[idx 1] d) { idx; } const localDistance d - path.cumulativeSegStart[idx]; const table path.tables[idx]; const curve path.curves[idx]; const t tFromDistance(table, localDistance); return cubicPoint(curve.p0, curve.p1, curve.p2, curve.p3, t); }这样全局动画进度f映射到全局距离d path.total * f再从这个距离反查出对应的曲线段和段内参数。段和段之间是否平滑、是否共用一个控制点都不影响距离分配它只按每段实际长度来分配速度这才是真正的路径匀速。5. 这些坑我基本都踩过提前说给你5.1 控制点变化后缓存失效查表法最大的前提是“曲线不变”。如果你的控制点可以被用户拖拽或者路径会动态生成旧的表就废了。我第一次实现时没注意拖动控制点后曲线形状已经变了但表还是旧的结果物体走了一条完全不存在的路径排查了半天才发现是缓存没失效。正确做法是给表加一个dirty/version标记。控制点变化时把对应的表标记为需要重建。如果是编辑器里的拖拽场景还可以加防抖比如停止拖动后 50ms 再重建表避免每一帧都重新算积分。5.2 退化曲线和零长度段有一种情况特别容易被人忽略控制点重合导致曲线某些区间的长度为 0。比如P0 P1曲线起始点的导数为 0物体在起点附近速度很小甚至为 0。如果此时直接做t tFromDistance(table, distance)segDist可能为 0ratio distance / 0会得到NaN。我在前面代码里已经加了保护if (segDist 1e-9) { return a.t; }但工程上还不够。如果一条曲线的总长度为 0或者多段路径里某一段的长度为 0你要先把整段跳过。一个简单办法是建表时记录每段累计长度如果某段累计长度和上一段相等就在查距离时直接忽略它。5.3 均匀速度是弧长均匀不是参数均匀这个坑看起来像废话但实际特别容易混。有一种错误做法是先用某种缓动函数处理时间比如eased easeInOut(t)再把eased当成贝塞尔参数直接取点。这样做的结果是在参数空间里“先慢后快”但物理空间里的速度依然不均匀因为参数和弧长根本不成正比。如果需求真的是匀速那就不要在时间和距离之间随意插入缓动。如果你想要“从慢到快”的动画效果应该把缓动作用在全局距离上也就是distance totalLength * ease(progress) t tFromDistance(table, distance)这是最容易理解、也最不容易出错的组合方式。5.4 二分查找的数组必须是单调累加累计长度表理论上一定是单调递增的但辛普森积分偶尔会因为在速度函数剧烈变化的地方采样不足产生极小误差。如果你使用“上一段长度 当前段长度”的时候前一个值比后一个值还大二分查找就会乱。建表时加一个cum Math.max(cum, prevCum len)的钳制操作能避免大部分数值问题。5.5 别把每帧dt table当成匀速还有一种半吊子实现每一帧都给distance speed * deltaTime然后用距离查表。这种方法本身没问题问题在于很多人会用“帧间隔不稳定”或“tab 切走又切回”导致距离跳变。建议统一用累计时间驱动progress clamp(elapsedTime / duration, 0, 1) distance totalLength * progress t tFromDistance(table, distance)这样不管帧率怎么变运动始终跟随真实时间切后台再回来也不会突然跳一段路。6. 最后说点个人体会把贝塞尔曲线从“参数均匀”改造成“弧长均匀”本质上不是学一个公式而是建立一套“距离到参数”的换算管道。我之前反复忘就是因为每次都在背公式没有把代码固化下来。后来我把buildLengthTable、tFromDistance、multiPointAtDistance这三件套封装成一个工具类所有路径动画项目都直接复用才真正不用每次重推。如果你也经常被这个问题困扰我建议别急着背数学推导先跑通查表法。先在一张纸上画出曲线再把曲线等分成 100 份用手算几条累计长度感受一下“参数等距”和“弧长等距”的区别。跑通之后理解弧长参数化就是水到渠成的事。最后分享一个小技巧如果曲线控制点变化不频繁可以把表的分辨率从 100 提到 500但不要盲目提到几千因为内存浪费不小而视觉上的改善在 200 之后已经很微弱。真正需要高精度时用牛顿迭代精修那一小段就够了。