资讯动态

贝塞尔曲线参数化曲率公式推导与平滑优化实战

发布时间:2026/10/2 10:19:56 来源:尧图企业网站定制
1. 从一条看起来没问题的曲线说起做图形和动画的朋友多少都碰过贝塞尔曲线。不管是做 Android 自定义 View 里的波浪、做数据可视化的平滑折线还是做游戏里的运动轨迹二次和三次贝塞尔几乎是绕不开的工具。我最早接触它是在一个图表库项目里需求很简单把一堆离散的统计点连成一条顺滑的曲线不能有折角。当时我随手用了最经典的做法——相邻两点之间用三次贝塞尔控制点按水平方向取三分之一间距。渲染出来第一眼看确实漂亮圆润流畅产品经理当场点头。结果放到真实数据上问题来了当相邻两点的数值变化特别剧烈比如从 5 直接跳到 90曲线在那一小段里会猛地向上拱起甚至出现明显的回勾——明明数据是单调递增的曲线却先往下沉一点再往上冲。更要命的是当我需要沿着曲线做等距离的图标动画比如让一个小圆点匀速沿曲线运动时速度忽快忽慢中间某段几乎瞬移两端却慢得像蜗牛。这两个问题的根子都指向同一个东西曲线的曲率。曲率描述的是曲线弯曲的剧烈程度曲率越大弯得越急曲率突变视觉上就是折而沿曲线运动的快慢本质上是弧长参数化的问题弧长又和曲率紧密相关。换句话说想让贝塞尔曲线既好看又能用绕不开对它的参数化方程求导、算出曲率公式再拿这个公式反过来指导我们怎么摆控制点、怎么做采样优化。这篇内容就是把我这几年在这件事上踩过的坑、推过的公式、写过的代码整理一遍从数学推导到工程落地把参数化曲率公式推导和曲线优化这两件事讲透。不管你是刚学贝塞尔的在校生还是被曲线抖动折磨过的客户端开发或者只是想理解平滑曲线背后原理的产品和技术爱好者应该都能拿到能直接用的东西。2. 先把贝塞尔的参数化表达捋清楚2.1 从参数 t 说起为什么贝塞尔天生就是参数曲线很多人一上手就想把贝塞尔写成 y f(x) 的形式然后发现怎么写都别扭原因在于贝塞尔曲线本质上是一条参数曲线它的 x 和 y 都是同一个参数 t 的函数t 从 0 走到 1点 (x(t), y(t)) 就扫过整条曲线。以三次贝塞尔为例给定四个控制点 P0、P1、P2、P3曲线上的点是这样算出来的def cubic_bezier(p0, p1, p2, p3, t): # 三次贝塞尔参数化方程t 属于 [0, 1] u 1 - t x (u**3) * p0[0] 3 * (u**2) * t * p1[0] \ 3 * u * (t**2) * p2[0] (t**3) * p3[0] y (u**3) * p0[1] 3 * (u**2) * t * p1[1] \ 3 * u * (t**2) * p2[1] (t**3) * p3[1] return (x, y)写成矩阵或者伯恩斯坦基函数的形式就是 B(t) Σ C(n,i) · (1-t)^(n-i) · t^i · Pi。这里的 (1-t)^(n-i)·t^i 就是伯恩斯坦基n 是次数i 从 0 到 n。理解这个式子有一个很直观的类比它本质上是加权平均。t 越小靠前的控制点权重越大t 越大靠后的控制点权重越大。所以 t0 时点在起点t1 时点在终点控制点则像磁铁一样把曲线往自己方向吸但永远不被曲线穿过除端点外。为什么不直接写成 yf(x)因为那样你就得处理竖直切线、多值、无穷斜率等一堆麻烦事而且没法优雅地表示曲线参数的变化率。参数化表达的好处是处处可导、处处光滑只要把导数算出来切线方向、曲率、弧长全都能顺畅地推下去。这也是后面所有推导的地基。记住一句话贝塞尔的 t 不是长度也不是时间它只是一个代参数字这是后面区分参数均匀和弧长均匀的关键。2.2 一阶导与二阶导曲率推导的全部弹药既然要算曲率就得先有速度向量和加速度向量。对三次贝塞尔求一阶导可以利用伯恩斯坦基的导数性质或者直接把展开式逐项求导结果会落到二次贝塞尔的形态上def cubic_bezier_d1(p0, p1, p2, p3, t): u 1 - t # 一阶导 3 * [(P1-P0)u^2 2(P2-P1)u t (P3-P2)t^2] dx 3 * ((p1[0]-p0[0])*(u**2) 2*(p2[0]-p1[0])*u*t (p3[0]-p2[0])*(t**2)) dy 3 * ((p1[1]-p0[1])*(u**2) 2*(p2[1]-p1[1])*u*t (p3[1]-p2[1])*(t**2)) return (dx, dy)一阶导向量 (x(t), y(t)) 就是切向量它的方向是切线方向它的模长是参数速度——注意是相对参数 t 的速度不是相对弧长的速度这是新手最容易混的点。二阶导同理落在一次贝塞尔形态上def cubic_bezier_d2(p0, p1, p2, p3, t): u 1 - t # 二阶导 6 * [(P2-2P1P0)(1-t) (P3-2P2P1)t] ddx 6 * ((p2[0]-2*p1[0]p0[0])*u (p3[0]-2*p2[0]p1[0])*t) ddy 6 * ((p2[1]-2*p1[1]p0[1])*u (p3[1]-2*p2[1]p1[1])*t) return (ddx, ddy)这两个函数是整个曲率计算的弹药库。我的习惯是在任何曲线项目里先把这两个函数写成工具类后面所有曲率、切线、法线、采样优化都从它们出发。写的时候有个小坑如果控制点用整数存做减法时一定要先转成浮点否则 (p2 - 2*p1 p0) 这类表达式会先按整数算再赋值误差会累积得很难看。这个坑我在一个 Android 项目里排查了大半天最后发现是 Kotlin 里 Int 控制点没转 Float曲线在细长段出现了肉眼可见的台阶。3. 参数化曲率公式的完整推导3.1 曲率的几何定义切线转角对弧长的变化率先从定义出发别急着套公式。曲率 κ 的几何含义是沿曲线走一小段弧长 ds切线方向转了 dθ 角那么 κ |dθ/ds|。这个定义的直觉非常好理解——如果一段路你走得很快但几乎不转弯曲率就小如果每走一点点就急转弯曲率就大。直线曲率为 0圆上每一点的曲率都是 1/RR 越大越平缓这也和直觉一致。现在问题变成怎么用 x(t)、y(t) 的导数把 dθ 和 ds 表达出来。切线的方向角 θ 满足 tanθ y(t)/x(t)对它求导并配合 ds sqrt(x^2 y^2) dt经过几步三角恒等变换就能得到那个流传很广的平面参数曲线曲率公式κ(t) (x(t)·y(t) - y(t)·x(t)) / (x(t)^2 y(t)^2)^(3/2)这个公式值得反复念叨。分子是叉积形式 xy - yx它衡量的是速度向量和加速度向量的错位程度。如果两个向量始终平行说明曲线在走直线或者沿直线加速曲率就是 0两个向量夹角越大错位越厉害曲率越大。分母是速度模长的三次方注意是3/2 次幂作用在模长平方上这个 3 次来自弧长的一次方除以参数的换元。分母的存在很关键同样的转角速度越大走过这段弧长所花的时间越短dθ/ds 反而越小所以曲率和速度是反比关系。这一点在后面讲参数均匀为什么不等速时会再次出现。3.2 把三次贝塞尔代进去得到 κ(t) 的代码级实现有了通用公式把上一节的两个导数函数代进去就能得到任意 t 处的曲率。实际写代码时我强烈建议把符号统一、分母加保护因为分母为 0 意味着速度为零cusp尖点或者重叠控制点都可能造成这种情况import math def curvature(p0, p1, p2, p3, t, eps1e-9): dx, dy cubic_bezier_d1(p0, p1, p2, p3, t) ddx, ddy cubic_bezier_d2(p0, p1, p2, p3, t) cross dx * ddy - dy * ddx # 分子速度与加速度的叉积 speed2 dx * dx dy * dy # 速度模长平方 if speed2 eps: return 0.0 # 退化点曲率按 0 处理避免除零 return cross / (speed2 ** 1.5) # 分母speed^3这里有几个工程上的细节值得展开。第一分子是有符号的正负号表示弯曲方向逆时针为正、顺时针为负如果你只关心弯曲程度取绝对值如果你要判断曲线往哪边鼓就保留符号这在做曲线偏移、生成平行曲线时特别有用。第二speed2 eps的退化处理不能省尤其在做曲线动画时控制点可能被拖到重合此时曲率理论上无定义工程上返回 0 比抛异常更稳。第三如果你在 GPU shader 里算曲率记得pow(x, 1.5)在移动端可能比sqrt(x*x*x)慢我一般写成x * math.sqrt(x)实测在中低端机型上有几个百分点的收益。3.3 端点曲率与圆弧近似一次有用的解析特判很多场景其实只需要端点或者中点的曲率这时候硬算通用公式有点浪费。三次贝塞尔在 t0 处的曲率有一个很漂亮的解析形式。因为 B(0) 3(P1-P0)B(0) 6(P2-2P1P0)代进公式化简后t0 的曲率只和控制点 P0、P1、P2 有关和 P3 无关t1 的曲率只和 P1、P2、P3 有关。这个结论的直觉是端点处的弯曲程度由起点附近的两个控制点决定远端控制点影响不到起点那么近的地方。更进一步如果我想用一段圆弧近似这段贝塞尔在做圆角、做图标描边时经常这么干有个很实用的经验规则三次贝塞尔用 k 4(√2 - 1)/3 ≈ 0.5523 这个比例来逼近四分之一圆时误差最小这就是所谓的magic number。它的来历正是让贝塞尔在端点和中点的曲率都等于圆的曲率 1/R联立解出来的。我第一次自己把 0.5523 推出来的时候还挺激动因为以前都是直接抄这个数。推导思路是设四分之一圆半径 R控制点放在 (R,0)、(R, kR)、(kR, R)、(0,R)让 t0 和 t0.5 处的曲率都逼近 1/R就能反解出 k。这个特判在画圆角矩形、做圆形进度条时能把曲线片数减少一半以上视觉几乎无差。4. 曲率公式怎么反过来指导曲线优化4.1 曲率连续性C1、C2 到底是什么体验光有曲率公式没用关键是拿它优化曲线。第一个要搞清的概念是连续性等级。两条曲线在连接点处位置相接叫 C0切线方向也一致叫 C1曲率也连续叫 C2。很多库里说平滑曲线其实只做到了 C1肉眼看折角没了但如果你观察反光或者沿着曲线跑高光还是能看出连接点那里曲率跳变像有个隐形的接缝。这就是为什么做高端图形的曲线非得追求 C2。对于三次贝塞尔拼接实现 C1 的条件是相邻两段在连接点处的一阶导成比例同向实现 C2 则要求二阶导也匹配。工程上的常用手法是在连接点两侧把相邻控制点放在连接点的同一条直线上并且关于连接点对称这样位置、切线、曲率就都连续了。我做过一个数据图表原本相邻两点各用一段三次贝塞尔、控制点水平取 1/3结果是 C0 都不是因为切线不匹配出现了小折角改成前段的第二个控制点、连接点、后段的第一个控制点共线且等距之后曲线才真正顺滑。这里有个代价共线等距会牺牲一点贴近数据的能力曲线会更软需要根据数据密度权衡。4.2 单调性保护防止曲线回勾的头号武器回到开头说的回勾问题。当数据单调递增但相邻差值悬殊时标准的三次贝塞尔平滑会在差值大的段里产生过冲。根因是控制点的 x 间距如果远大于两点实际 x 间距或者控制点方向没跟数据趋势走曲线的 x(t) 或者 y(t) 就会在中间变号表现为局部反向。判断标准其实可以量化如果控制点在前一段上的投影方向和数据趋势相反或者相邻控制点的连线与数据段方向夹角超过某个阈值就说明这段有回勾风险。我用的解决方案有两种实践下来都很稳。方案 A 是限制控制点的水平位置不超出相邻数据点的 x 区间也就是常说的控制点单调约束让 P1.x 和 P2.x 始终落在对应的数据点 x 区间内这样 x(t) 恒正x 方向不会回退。方案 B 是同时约束斜率让每段贝塞尔的端点斜率落在相邻数据差值的合理范围内类似单调三次插值里的 Fritsch-Carlson 限幅思路。方案 B 效果更好但实现略复杂一般图表库用方案 A 就够了视觉上已经能消除绝大多数回勾。我把两种方案的取舍整理成一张表优化方案约束对象实现难度消除回勾效果适用场景方案 A 控制点单调控制点 x 坐标区间低中高时间序列图表、快速实现方案 B 端点斜率限幅端点斜率范围中高高精度数据可视化方案 C 弧长重参数化t 与弧长映射高间接改善动画等速、轨迹规划4.3 弧长参数化让沿曲线运动真正匀速这是我踩坑最深的一块。一开始我天真地以为让 t 从 0 线性走到 1点就会沿曲线匀速前进。事实完全不是——因为 t 是参数不是弧长而贝塞尔的速度模长 |B(t)| 在区间内是变化的所以 t 均匀意味着参数速度均匀但弧长速度不均匀。表现就是开头说的点在某些段飞快、某些段慢吞吞。解决办法是建立 t 到弧长 s 的映射。工程上最常用的是弧长表 二分/线性插值先用足够密的采样比如把 t 分成 100 到 500 份累加每段的直线长度作为弧长近似得到一张 s 到 t 的对应表动画时按弧长 s 反查 t。更精确的可以用高斯-勒让德数值积分算弧长但样本法实现简单、性能可控99% 的场景够用。关键是采样密度要自适应——曲率大的地方采样密一点曲率小的地方稀疏一点这正是曲率公式的用武之地用曲率大小决定采样步长曲率大处步长小。我在一个地图轨迹回放项目里就是这么做点数比均匀采样少了约 40%轨迹观感反而更顺。def build_arc_table(p0, p1, p2, p3, samples256): table [0.0] prev cubic_bezier(p0, p1, p2, p3, 0.0) for i in range(1, samples 1): t i / samples cur cubic_bezier(p0, p1, p2, p3, t) seg math.hypot(cur[0]-prev[0], cur[1]-prev[1]) table.append(table[-1] seg) # 累积弧长 prev cur return table # table[i] 对应 ti/samples 的弧长 def t_for_arc(table, target_s, samples256): total table[-1] target_s min(max(target_s, 0.0), total) # 二分找到所在区间再做线性插值 lo, hi 0, samples while lo hi: mid (lo hi) // 2 if table[mid] target_s: lo mid 1 else: hi mid idx max(lo, 1) s0, s1 table[idx-1], table[idx] if s1 - s0 1e-12: return idx / samples ratio (target_s - s0) / (s1 - s0) return (idx - 1 ratio) / samples4.4 用曲率做自适应细分把点花在刀刃上曲线渲染和碰撞检测里通常要把贝塞尔离散成折线离散得越密越准但越费。均匀细分的问题是直线段处浪费了大量点而曲率大的弯处点又不够。正确做法是按曲率自适应细分——在曲率超过阈值的地方多切曲率小的地方少切。常见的判据是弦高误差如果一段弧的弦到弧的最大偏差超过阈值 ε就继续二分否则停。弦高可以用曲率估算偏差约等于 κ·L²/8L 是弦长。所以曲率大就要更短的弦迭代下去自然就在弯处生成了更密的点。我实现过一个递归细分函数逻辑是对当前 t 区间如果控制点足够平控制点到弦的距离小于 ε就直接用弦近似否则在中间切一刀递归处理两半。这个判据本质上是曲线被压扁了就用直线代替比直接看曲率更稳定因为它顺带处理了近乎共线但很长的段。实测在同样的视觉误差下递归细分比均匀细分省了 50% 到 70% 的点数在移动端渲染大量曲线时非常值得。5. 完整实操从参数到优化曲线的落地流程5.1 参数选择与初始控制点生成实际做平滑折线第一步是把离散数据点转成贝塞尔控制点。我用得最多的初始方案是 Catmull-Rom 转贝塞尔给定序列点对每一个相邻段用前后点算出该段的两个控制点公式大致是 P1 Pi (Pi1 - Pi-1)/6 · tensionP2 Pi1 - (Pi2 - Pi)/6 · tensiontension 常用 0.5。这样得到的曲线天然 C1 连续切线跟随数据趋势比生硬地水平取 1/3 好太多。Catmull-Rom 的好处是它经过所有数据点而普通贝塞尔平滑不一定过点这对图表很重要——数据点必须在曲线上。但转出来还不够得再用前面说的方案 A 或 B 做单调性和过冲修正。我的流程是先 Catmull-Rom 出初始控制点然后检查每段是否过冲比较控制点 y 是否超出两端点 y 的范围超了就裁剪回范围内或者缩小 tension。tension 是个很好的调节旋钮tension 越小曲线越硬越贴近折线越大越软越平滑实际项目里我会把它做成可配置项甚至暴露给用户调。5.2 关键步骤的代码组织与参数计算把整个流程串起来我一般会组织成这么几个清晰的角色一个 Bezier 工具类负责求点和求导一个曲率模块负责算 κ一个平滑器负责从数据点生成控制点并做约束一个采样器负责弧长表和自适应细分。写代码时的心得是别把所有逻辑堆在一个函数里因为后期你会不断调参数模块化后改一处不影响其他。参数计算上有几个数我记了多年。弧长表采样数 256 对短曲线足够长曲线或高曲率曲线我会按控制点包围盒对角线长度除以像素分辨率来动态定一般 128 到 512 之间。自适应细分的弦高阈值 ε 取屏幕半个像素到一个像素最合适比这更小肉眼无感纯浪费更大则能看出棱角。曲率阈值做等速动画时我通常不用绝对曲率而是用归一化曲率除以最大曲率来分档避免不同尺度的曲线表现不一致。def smooth_polyline(points, tension0.5, eps0.5): n len(points) if n 2: return [] segs [] for i in range(n - 1): p0 points[i - 1] if i - 1 0 else points[i] p1 points[i] p2 points[i 1] p3 points[i 2] if i 2 n else points[i 1] c1 (p1[0] (p2[0]-p0[0])/6*tension, p1[1] (p2[1]-p0[1])/6*tension) c2 (p2[0] - (p3[0]-p1[0])/6*tension, p2[1] - (p3[1]-p1[1])/6*tension) # 单调性保护控制点 x 不越出该段区间 lo_x, hi_x min(p1[0], p2[0]), max(p1[0], p2[0]) c1 (min(max(c1[0], lo_x), hi_x), c1[1]) c2 (min(max(c2[0], lo_x), hi_x), c2[1]) segs.append((p1, c1, c2, p2)) return segs5.3 一次真实的调优记录从抖动到顺滑说个完整案例。有个实时曲线展示的场景传感器数据每秒来一批我需要在屏幕上画一条持续向右延伸的平滑线。初版就是每来一批点用 Catmull-Rom 平滑重画整条线结果有三个毛病一是曲线末端不停甩尾因为末端缺后续点控制点算出来会向外拽二是数据抖动时曲线跟着抖三是曲线长了以后每帧重算整条性能吃紧。我按顺序解决了这三件事。甩尾问题对末端段用端点镜像补一个虚拟的后续点或者直接让末端控制点退化为端点本身切线为零我选了后者末端看起来是自然收束的。抖动问题加了一层简单的滑动平均或者一阶低通滤波但滤波引入了延迟所以我用曲率来判断——只在曲率大数据真的在快速变化时才全量响应曲率小平稳段时重点平滑上次的曲线状态这样既有响应又不抖。性能问题缓存历史段的采样点每帧只重算受影响的最后几段这个脏段策略把 CPU 占用从 40% 降到 8% 左右。调完之后曲线观感提升非常明显尤其是末端不再乱甩转折处也不再有那种抽搐感。回头看真正起作用的就是曲率这个量它同时扮演了哪里该细分哪里该平滑哪里变化快三个决策的判据。6. 常见问题与避坑速查6.1 曲率计算类问题分母为零导致 NaN。最常见原因是速度模长为零发生在控制点重合或退化时。处理办法就是前面代码里的 eps 保护返回 0。如果你的曲线存在真实的尖点比如两段直线反向相接曲率本就无定义别硬算。曲率符号反了。如果你用的是 y 轴朝下的屏幕坐标系几何上的逆时针和数学坐标系是反的曲率正负号会整体相反。这不影响取绝对值后的弯曲程度但影响你做偏移方向判断时千万别想当然。我的经验是统一在一个坐标系里推公式到渲染层再翻转不要在中间混着来。大尺度坐标下精度丢失。当控制点坐标是几万甚至几十万的量级比如地图经纬度直接当像素用导数的差分会被放大曲率算出来噪声很大。做法是先把坐标平移到曲线自身包围盒中心、归一化到合适量级再算最后再变换回去。6.2 曲线优化类问题回勾消除不彻底。多半是控制点只在 x 上做了约束但 y 方向仍有反向。单调性保护要对每个轴分别判断或者用斜率限幅方案。另外 tension 太大也会诱发过冲调小它往往立竿见影。弧长表精度不够导致动画抖动。采样数太少是主因尤其是长曲线。经验值每 10 像素弧长至少一个采样点。还有一个隐蔽问题是弧长表在曲线被修改后没有重建用着旧表当然抖。自适应细分出现裂缝。如果你在多段连接处用不同细分密度连接点附近的点没对齐会形成细缝。解决要么在连接点强制对齐采样点要么统一用连续参数从 0 到 1 扫完所有段再细分。我把这些高频问题整理成速查表出问题先对这张表过一遍八成能找到方向现象最可能原因快速排查修复方向曲线回勾控制点方向与趋势相反画控制点连线看夹角控制点单调/斜率限幅动画忽快忽慢未做弧长参数化打印各段速度模长建弧长表反查 t连接处有接缝感仅 C1 连续检查连接点曲率突变对称共线控点做 C2渲染点浪费均匀细分统计曲率分布弦高自适应细分端点甩尾末端缺后续控制点看末端切线方向端点退化/镜像补点曲率 NaN速度模长为零定位重合控制点加 eps 保护6.3 我的实操心得心得一先可视化再优化。曲率看不见摸不着最好写个小工具把曲率按 t 画成曲线或者给曲线按曲率上色红色高曲率、蓝色低曲率很多问题一眼就暴露了。我那个回勾问题是给曲线上了曲率色之后才定位到具体哪一段的比盯着几何形状猜快多了。心得二参数别写死在代码里。tension、eps、采样数这些我全部做成可调调试时用一个滑块实时改找到舒服的值再固化。这个习惯帮我省了大量改数字-重新编译的时间。心得三性能优化前先量化。说曲线卡先用 profiler 看是求导贵、采样贵还是渲染贵别上来就乱优化公式。我遇到过一次曲线卡顿最后发现瓶颈根本不在数学计算而在每帧重新分配数组对象触发的 GC改成复用缓冲就解决了。7. 关于这套方法的适用边界说了这么多也得承认它不是万能的。曲率公式和这套优化流程最适合的是平面三次及以下贝塞尔的场景也就是绝大多数 UI、图表、2D 动画需求。如果你做的是三维曲面上的曲线、有理贝塞尔带权控制点能精确表示圆锥曲线公式形式会变权重会进入分母那套解析特判也不再直接适用得回到数值方法。另外当控制点用极小的张力逼近折线时曲率会在数据点附近出现尖峰这时候低通滤波曲率比硬套公式更实用。还有个容易被忽略的边界曲率连续不等于视觉舒服。有时候严格 C2 的曲线反而显得太肉、缺乏设计的力度感很多优秀的设计故意在局部保留曲率不连续来制造节奏。所以这套工具是给你精确控制和量化感知用的不是让你无脑把一切曲线都推到最光滑。我现在的做法是默认可视化曲率分布然后由设计需求决定哪里需要连续、哪里故意断开把数学当画笔而不是当尺子。曲线做到最后拼的还是对什么看起来对的判断公式只是让这个判断变得可测量、可复现。

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

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

免费获取报价 →
↑