ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Canvas贝塞尔曲线动画实操:从菜鸟到丝滑拖尾

Canvas贝塞尔曲线动画实操:从菜鸟到丝滑拖尾 第一次接到用 Canvas 画曲线动画的需求是在给一个直播 H5 写礼物特效的时候一颗小球要从屏幕左下角飞向右上角身后还要拖一条柔和的轨迹。我当时“贝塞尔曲线”四个字听过公式也看过两三遍真到写代码还是只会lineTo硬画结果画出来的路径像折线被设计师一眼看穿。所以这篇完全是写给前端菜鸟的实操笔记。我会从贝塞尔曲线到底在做什么讲起把 Canvas 怎么落笔、二阶三阶 API 怎么用、动画怎么做到“看起来丝滑”这些事拆开揉碎最后再讲我实际踩过的坑。你不需要学过图形学只需要打开编辑器跟着敲一遍就行。1. 贝塞尔曲线到底是什么先忘掉公式用一个“切分点”理解它1.1 贝塞尔曲线解决的是“从 A 到 B 时姿势怎么摆”如果只要求从 A 点到 B 点直线的确够了。但真实需求里曲线往往有明确的“意图”我想要它先往上拱、再往下沉或者从左侧平缓转进、到右侧急转出去。这些意图在几何上靠什么表达答案是控制点。贝塞尔曲线最通俗的理解是起点和终点之间塞进一个或几个“控制点”。曲线本身不经过控制点但会被控制点像磁铁一样“吸”过去。控制点离得越近曲线越偏向它控制点离得越远曲线被拉得越弯。二阶贝塞尔P0起点、P1控制点、P2终点比如一条抛物线。三阶贝塞尔P0起点、P1 和 P2两个控制点、P3终点比如一个 S 形或来回转折的曲线。你可以把二阶和三阶看成“曲线的两个常见档位”。Canvas 的 API 恰好也只提供这两档刚好覆盖绝大多数前端动效需求。1.2 用“切分”建立直觉德卡斯特里奥的一次切割有个叫“德卡斯特里奥”的算法思想比公式好懂得多。拿二阶贝塞尔举例假设 t 是 0 到 1 之间的一个进度值比如 t0.5。先在 P0 到 P1 这条线段上取一个 50% 位置的点 A再在 P1 到 P2 这条线段上取一个 50% 位置的点 B连接 A、B 两点在这条新线段上再取 50% 位置的点 C。这个 C 点就是贝塞尔曲线在 t0.5 时对应的位置。t 从 0 走到 1C 点画出来的轨迹就是整条曲线。这个思路的好处是它直接告诉你“曲线上任意一个点都是周围几个点在不同比例下混合的结果”。理解了这一点后面写动画时你就知道怎么让一个小球沿着曲线运动——其实就是让 t 从 0 慢慢走到 1然后每个时刻算出对应的点坐标。1.3 参数方程先背下来后面写动画要用虽然 Canvas 有现成 API画静态曲线不需要自己算点但做“小球沿曲线运动”这类动画时你必须自己根据 t 算出曲线上的坐标。二阶和三阶的参数方程长这样二阶B(t) (1 - t)^2 · P0 2t(1 - t) · P1 t^2 · P2三阶B(t) (1 - t)^3 · P0 3t(1 - t)^2 · P1 3t^2(1 - t) · P2 t^3 · P3写成函数更直观function quadPoint(p0, p1, p2, t) { const mt 1 - t; return { x: mt * mt * p0.x 2 * mt * t * p1.x t * t * p2.x, y: mt * mt * p0.y 2 * mt * t * p1.y t * t * p2.y, }; } function cubicPoint(p0, p1, p2, p3, t) { 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, }; }你暂时不需要理解每一项为什么长这样只要记住把 t 从 0 到 1 依次代入就能得到曲线上的点。这个认知是你写贝塞尔动画的“地基”。2. 动手前先调教 Canvas 画布坐标系、真尺寸和高分屏2.1 Canvas 宽高和 CSS 尺寸是两套东西很多新手第一次设 Canvas 只写stylewidth: 520px; height: 360px结果画出来图形模糊或比例不对。原因是Canvas 的真实分辨率由width和height属性决定不是由 CSS 决定。canvas.width和canvas.height画布内部的像素尺寸决定你能画多少像素。CSS 的宽高只是画布在页面上显示的尺寸。如果你只设置了 CSS 尺寸Canvas 默认内部是 300 x 150。浏览器会把这块 300x150 的小图等比例拉大显示效果自然发虚。正确写法是canvas idcurve width520 height360/canvas或者用 JS 设置const canvas document.getElementById(curve); canvas.width 520; canvas.height 360;这里还要注意一点Canvas 的坐标系原点在左上角x 轴向右y 轴向下。也就是说y 值越大越靠下和数学课上常见的 y 轴方向是反的。写代码时控制点坐标别凭直觉反着来。2.2 高分屏适配不想看到模糊曲线就先做这一步Mac 的 Retina 屏、手机的高清屏上window.devicePixelRatio往往是 2 或 3。这意味着一个 CSS 像素里要放 2x2 甚至 3x3 个物理像素。如果你 Canvas 的真实宽度只等于 CSS 宽度浏览器会强行拉伸曲线边缘就发糊。标准做法是把 Canvas 的真实尺寸乘以 dpr再通过ctx.scale把坐标系拉回来function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }之后写代码时坐标仍然按 CSS 像素来写setTransform会帮你自动放大。这样在 Retina 屏上画出来的线条清晰锐利不会像隔了一层雾。2.3 路径三连beginPath、moveTo、strokeCanvas 画线很像手持一支笔在纸上画beginPath()通知画布“我要开始一条新路径了”相当于把笔离开纸面moveTo(x, y)把笔移动到某个位置但不画线lineTo(x, y)从当前笔画位置连一条线到新位置stroke()把这条路径真正描边显示出来。如果忘了beginPath()新路径会和旧路径连在一起出现“串线”的诡异效果。这个坑我后面专门讲。你只需要先形成肌肉记忆一段独立图形一定是beginPath → 移动/连线 → stroke。3. 画出第一笔曲线quadraticCurveTo 与 bezierCurveTo 的正确打开方式3.1 quadraticCurveTo一个控制点画出抛物线先看一段完整可运行代码。假设画布尺寸 520x360起点 P0 在左下控制点 P1 在正上方终点 P2 在右下const canvas document.getElementById(demo); const ctx canvas.getContext(2d); canvas.width 520; canvas.height 360; ctx.lineWidth 3; ctx.strokeStyle #1677ff; ctx.lineCap round; ctx.beginPath(); ctx.moveTo(60, 300); // 起点 ctx.quadraticCurveTo(260, 60, 460, 300); // 控制点P1, 终点P2 ctx.stroke();API 参数顺序要记牢quadraticCurveTo(控制点x, 控制点y, 终点x, 终点y)起点不是参数而是当前路径的位置也就是之前moveTo定的点。这是新手最容易懵的地方为什么这个 API 只有三个点而不是四个因为它默认把“当前点”当作了起点。想看控制点是怎么“拉”曲线的可以画两条辅助虚线把 P0-P1、P1-P2 连起来ctx.save(); ctx.strokeStyle rgba(0, 0, 0, 0.2); ctx.lineWidth 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(60, 300); ctx.lineTo(260, 60); ctx.lineTo(460, 300); ctx.stroke(); ctx.restore();你会发现蓝色曲线并不经过 P1而是被它“吸”向中间。P1 越靠近上方曲线拱得越高P1 越靠近左右两侧曲线越平。这个直观感受比记任何描述都有用。3.2 bezierCurveTo两个控制点带来的 S 形转折二阶曲线只有“单方向弯折”想画 S 形就得用三阶贝塞尔——它有两个控制点可以中途改变弯曲方向ctx.beginPath(); ctx.moveTo(60, 280); ctx.bezierCurveTo(160, 80, 340, 80, 460, 280); ctx.stroke();参数顺序bezierCurveTo(控制点1x, 控制点1y, 控制点2x, 控制点2y, 终点x, 终点y)在当前这个例子里两个控制点都比较靠上但一个偏左、一个偏右曲线会先往左上方向拱再转向右上方向拱整体看就是一个类似 S 形或波浪的曲线。三阶贝塞尔之所以在 SVG、CSS 动画、AE 导出路径里到处都是就是因为它“表达能力”更强一条线就能表达更复杂的趋势。3.3 两个 API 的选型对比API控制点数量常见用途记忆口诀quadraticCurveTo1 个抛物线、简单过渡、小球拖尾控制点 终点bezierCurveTo2 个S 形、复杂曲线、图表平滑线控制点1 控制点2 终点做 H5 动效时我的习惯是先尝试二阶形状不够再升级三阶。控制点越多调节成本也越高没必要一开始就上最复杂的。4. 让小球沿曲线“丝滑”运动动画循环与匀速参数的坑4.1 最小动效循环t 从 0 走到 1静态曲线只是第一步很多场景需要“动画”。比如一个礼物图标沿着贝塞尔曲线飞过或者一条轨迹线逐渐生长出来。核心逻辑就是把 t 从 0 缓慢增加到 1每帧算出当前点坐标然后清屏重绘。下面这个例子红色小球会沿二阶贝塞尔曲线循环运动const canvas document.getElementById(anim); const ctx canvas.getContext(2d); canvas.width 520; canvas.height 360; const p0 { x: 60, y: 300 }; const p1 { x: 260, y: 40 }; const p2 { x: 460, y: 300 }; function getPoint(t) { const mt 1 - t; return { x: mt * mt * p0.x 2 * mt * t * p1.x t * t * p2.x, y: mt * mt * p0.y 2 * mt * t * p1.y t * t * p2.y, }; } function drawCurve() { ctx.strokeStyle #1677ff; ctx.lineWidth 3; ctx.lineCap round; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); for (let i 1; i 80; i) { const p getPoint(i / 80); ctx.lineTo(p.x, p.y); } ctx.stroke(); } function drawBall(t) { const p getPoint(t); ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.fillStyle #ff4d4f; ctx.fill(); } let t 0; let last 0; const speed 0.4; // 每秒 t 增加 0.4 function frame(time) { const dt last ? (time - last) / 1000 : 0; last time; t dt * speed; if (t 1) t - 1; ctx.clearRect(0, 0, canvas.width, canvas.height); drawCurve(); drawBall(t); requestAnimationFrame(frame); } requestAnimationFrame(frame);注意这里绘制曲线用了 80 段lineTo来逼近而不是直接quadraticCurveTo。原因很简单既然小球位置是靠参数方程算出来的用同样方式采样曲线就能保证“曲线本身”和“小球轨迹”严丝合缝。如果只是静态展示直接用quadraticCurveTo更快你也可以混合使用——画曲线用 API算小球位置用参数方程。4.2 隐藏得很深的“不丝滑”问题t 匀速不等于速度匀速如果你把上面代码跑起来会感觉小球在某些位置明显快、某些位置明显慢。这不是你的错觉。贝塞尔曲线里 t 是参数不是距离t 匀速增加时曲线上移动的像素距离并不均匀。尤其控制点离起点比较近时小球会在刚出发时“冲”一下靠近终点又“拖”一下。想要视觉上的丝滑需要按“弧长”来控制运动而不是按 t。思路是先把曲线采样成很多小段计算每一段的累计长度每帧让小球前进固定的像素距离根据累计长度反推出当前应该使用的 t。预计算弧长表const samples []; function precomputeArcLength(steps 100) { let total 0; let prev getPoint(0); samples.push({ t: 0, dist: 0 }); for (let i 1; i steps; i) { const t i / steps; const p getPoint(t); total Math.hypot(p.x - prev.x, p.y - prev.y); samples.push({ t, dist: total }); prev p; } return total; } function getTByDistance(total, dist) { const d ((dist % total) total) % total; for (let i 0; i samples.length - 1; i) { const a samples[i]; const b samples[i 1]; if (d a.dist d b.dist) { if (b.dist a.dist) return a.t; const ratio (d - a.dist) / (b.dist - a.dist); return a.t (b.t - a.t) * ratio; } } return 1; }动画循环里不再维护 t而是维护一个累计距离dist每帧加上dt * pixelSpeed再通过getTByDistance找到对应的 t最后取点绘制。这样小球在弧线上匀速前进观感立刻“顺”很多。提示这个细节属于进阶优化。菜鸟阶段不用死磕但如果你做直播礼物、H5 动效类需求迟早会遇到“为什么这条曲线跑起来一快一慢”的问题到时候回来看这里就有答案了。5. 实战场景散点平滑、拖拽控制点与可复用的调试面板5.1 给你一行散点怎么画出一条平滑曲线数据图表在前端很常见比如折线图。直接用lineTo连起来点多了就是一条折线又硬又难看。一个经典的做法是“中点逼近法”把每个原始点当作控制点取相邻两个原始点的中点作为每一段quadraticCurveTo的终点。function drawSmoothLine(ctx, points) { if (points.length 2) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i 1; i points.length - 1; i) { const midX (points[i].x points[i 1].x) / 2; const midY (points[i].y points[i 1].y) / 2; ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke(); }效果是原本尖锐的折角变成了圆润过渡。注意这个方法是“逼近”而不是“精确穿过所有点”如果产品要求曲线必须严格经过每个数据点那要换成 Catmull-Rom 转换贝塞尔的插值方案复杂度会高一些。大多数视觉平滑场景中点逼近已经够用。5.2 做一个可拖拽控制点的贝塞尔调试器写贝塞尔曲线时最痛苦的是“盲调坐标”在代码里改数字刷新页面再改数字。更好的方式是把控制点画到画布上允许鼠标直接拖动实时看效果。const editor document.getElementById(editor); const ctx2 editor.getContext(2d); editor.width 520; editor.height 360; const points { p0: { x: 60, y: 300 }, p1: { x: 260, y: 60 }, p2: { x: 380, y: 180 }, p3: { x: 460, y: 300 }, }; let dragging null; function hitTest(x, y) { for (const key in points) { const p points[key]; if (Math.hypot(p.x - x, p.y - y) 14) return key; } return null; } editor.addEventListener(pointerdown, (e) { const rect editor.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; dragging hitTest(x, y); }); editor.addEventListener(pointermove, (e) { if (!dragging) return; const rect editor.getBoundingClientRect(); points[dragging].x e.clientX - rect.left; points[dragging].y e.clientY - rect.top; redraw(); }); editor.addEventListener(pointerup, () { dragging null; }); editor.addEventListener(pointerleave, () { dragging null; }); function redraw() { ctx2.clearRect(0, 0, editor.width, editor.height); ctx2.lineWidth 3; ctx2.strokeStyle #1677ff; ctx2.beginPath(); ctx2.moveTo(points.p0.x, points.p0.y); ctx2.bezierCurveTo(points.p1.x, points.p1.y, points.p2.x, points.p2.y, points.p3.x, points.p3.y); ctx2.stroke(); // 画控制线段和圆点 ctx2.setLineDash([4, 4]); ctx2.strokeStyle rgba(0, 0, 0, 0.3); ctx2.beginPath(); ctx2.moveTo(points.p0.x, points.p0.y); ctx2.lineTo(points.p1.x, points.p1.y); ctx2.lineTo(points.p2.x, points.p2.y); ctx2.lineTo(points.p3.x, points.p3.y); ctx2.stroke(); for (const key in points) { const p points[key]; ctx2.beginPath(); ctx2.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx2.fillStyle key p0 || key p3 ? #ff4d4f : #52c41a; ctx2.fill(); } } redraw();这里用了pointerdown / pointermove / pointerup一套组合覆盖鼠标和触屏。把这段代码保存成 HTML 文件你以后写任何复杂曲线前都能先拖一拖、找找手感再回代码里填数字。这比纯靠想象高效得多。提示如果页面里有高清屏适配记得先调用前面写的setupCanvas并把clientX - rect.left得到的坐标转换成逻辑坐标。用了setTransform(dpr, 0, 0, dpr, 0, 0)后CSS 像素坐标和 Canvas 逻辑坐标一致拖拽位置不会偏。5.3 场景延伸不只是画线还能做更多把贝塞尔曲线学会后你还能用它做出不少实用效果数据可视化股票的 K 线趋势、走势图的平滑曲线H5 动效礼物飞入、引导箭头、气泡飘动加载动画文字绘制动画、线条生长效果Canvas 小游戏敌人巡逻路径、子弹跟踪轨迹。原理都一样要么用参数方程计算点位置要么用 Canvas API 直接描边。曲线本身不复杂复杂的是“你想让它表达什么”。6. 我掉进去过的三个坑模糊、串线和鬼影6.1 坑一canvas.width 设置到了 CSS 里我早期给 Canvas 调样式时习惯性把宽高写进style结果画出来的贝塞尔曲线总是发虚。排查半天发现 Canvas 内部还是默认的 300x150所有漂亮曲线都被 CSS 暴力拉大了。现在我的习惯是初始化任何 Canvas第一件事就是明确canvas.width和canvas.height。如果是动态创建就再检查一次getBoundingClientRect()拿到的尺寸确保真实尺寸和显示尺寸一致。6.2 坑二路径没有 beginPath所有曲线糊成一片有一段时间我写循环画多条曲线发现屏幕上线条越来越多越画越“脏”。原因很简单每次stroke()之后路径没有重置新旧线段全保留在同一个路径里下一次stroke()会把之前的线条再描一遍。正确做法是每条独立曲线的开头都调用beginPath()。尤其注意moveTo和lineTo之后哪怕画了辅助线也要在正式曲线前重新beginPath否则辅助虚线可能混进主曲线里。6.3 坑三动画循环中清屏时机不对出现“鬼影”动画里每帧开头必须clearRect但如果清屏区域算错旧画面就会残留。比如画布是 520x360你只清了局部区域小球跑到区域外就会留下残影。另一个相关问题是如果你故意不清屏可以把轨迹线留在画布上来一版“拖尾生长动画”但抖动的清屏会导致闪烁。我的建议是默认每帧clearRect(0, 0, canvas.width, canvas.height)需要保留轨迹时再改为“只在画新一帧之前把上一帧内容复制保留”这类特殊做法而不是依赖不清屏这种碰运气的方式。提示如果想做贝塞尔曲线“生长”动画也就是线条一点点画出来可以每帧根据当前 t 只绘制曲线的前半段后半段留空。这时clearRect的逻辑需要放在 draw 之前顺序反了也会看到残影。最后分享一个我个人的习惯现在写任何带曲线的需求第一步都会打开一个拖拽控制点的调试面板先把形状调到满意再记录控制点坐标回填到业务代码里。贝塞尔曲线这个知识点说穿了就是“起点、终点、控制点”三个角色你在画布上拖一次、看一次曲线变化比背十遍公式都管用。菜鸟阶段不怕不懂数学怕的是不敢动手试。把上面这几个例子跑通你就已经超过大部分只会lineTo的同行了。
返回列表