如何让HTML5 Canvas中的线条动画更流畅?
问题描述
使用HTML5 Canvas + JavaScript/jQuery开发网格点间的多条动画路线,目标是让二次贝塞尔曲线从A点逐步流畅绘制到B点,但当前实现的线条是突然完整显现,动画效果生硬。原代码如下:
原代码
HTML
<!DOCTYPE html> <html lang="pt-BR"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Rotas de Pontos</title> <style> body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; } #canvas-container { width: 600px; height: 600px; position: relative; } canvas { background-color: white; } </style> </head> <body> <div id="canvas-container"> <canvas id="canvas" width="600" height="600"></canvas> </div> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> </body> </html>
JavaScript
$(document).ready(function () { const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const points = []; const routes = []; const pointRadius = 10; const gridSize = 60; // Espaçamento entre os pontos const numRows = Math.floor(canvas.width / gridSize); const numCols = Math.floor(canvas.height / gridSize); const animationDuration = 2000; // Tempo total da animação (ajustável) // Função para desenhar um ponto function drawPoint(x, y, color = 'black') { ctx.beginPath(); ctx.arc(x, y, pointRadius, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = color; ctx.stroke(); } // Função para desenhar a curva completa de A para B function drawFullCurve(pointA, pointB, colorStart, colorEnd) { const midX = (pointA.x + pointB.x) / 2; const midY = (pointA.y + pointB.y) / 2 - 50; // Elevação da curva const gradient = ctx.createLinearGradient(pointA.x, pointA.y, pointB.x, pointB.y); gradient.addColorStop(0, colorStart); gradient.addColorStop(1, colorEnd); ctx.beginPath(); ctx.moveTo(pointA.x, pointA.y); ctx.quadraticCurveTo(midX, midY, pointB.x, pointB.y); ctx.strokeStyle = gradient; ctx.lineWidth = 2; ctx.stroke(); } // Função para animar a revelação da curva de A para B function animateCurve(pointA, pointB, colorStart, colorEnd, duration) { let startTime = performance.now(); function animateStep(timestamp) { const elapsedTime = timestamp - startTime; const progress = Math.min(elapsedTime / duration, 1); // Progresso linear de 0 a 1 // Não limpar o canvas inteiro! Manter as rotas anteriores ctx.save(); ctx.beginPath(); ctx.moveTo(pointA.x, pointA.y); // Calcular posição intermediária com interpolação linear const t = progress; const midX = (pointA.x + pointB.x) / 2; const midY = (pointA.y + pointB.y) / 2 - 50; const revealX = (1 - t) * ((1 - t) * pointA.x + t * midX) + t * ((1 - t) * midX + t * pointB.x); const revealY = (1 - t) * ((1 - t) * pointA.y + t * midY) + t * ((1 - t) * midY + t * pointB.y); ctx.quadraticCurveTo(midX, midY, revealX, revealY); ctx.clip(); drawFullCurve(pointA, pointB, colorStart, colorEnd); ctx.restore(); drawPoint(pointA.x, pointA.y, colorStart); drawPoint(pointB.x, pointB.y, colorEnd); if (progress < 1) { requestAnimationFrame(animateStep); } } requestAnimationFrame(animateStep); } // Gerar os pontos for (let i = 0; i < numRows; i++) { for (let j = 0; j < numCols; j++) { const x = i * gridSize + gridSize / 2; const y = j * gridSize + gridSize / 2; points.push({ x, y }); drawPoint(x, y); } } // Gerar 20 rotas aleatórias function generateRandomRoutes() { for (let i = 0; i < 20; i++) { const startPoint = points[Math.floor(Math.random() * points.length)]; const endPoint = points[Math.floor(Math.random() * points.length)]; routes.push({ startPoint, endPoint }); } } generateRandomRoutes(); // Função para animar as rotas simultaneamente function animateRoutes() { routes.forEach(route => { const colorStart = 'blue'; const colorEnd = 'red'; const { startPoint, endPoint } = route; // Executa a animação para cada rota sem limpar o canvas animateCurve(startPoint, endPoint, colorStart, colorEnd, animationDuration); }); } // Executar a animação das rotas animateRoutes(); });
优化方案
原代码核心问题是用clip()裁剪完整曲线模拟逐步绘制,冗余度高且易出现视觉断层。以下是针对性优化:
1. 直接绘制曲线分段路径
二次贝塞尔曲线任意点可通过公式直接计算:B(t) = (1-t)² * P0 + 2*(1-t)*t * P1 + t² * P2
其中:
P0:起点坐标P1:控制点坐标P2:终点坐标t:进度值(0到1)
基于t值绘制从起点到当前t对应点的曲线段,替代裁剪逻辑。
2. 统一全局动画循环
避免每个路线单独发起requestAnimationFrame,改用全局循环统一更新所有路线进度,提升性能。
3. 静态元素只绘制一次
网格点属于静态元素,初始化时绘制一次即可,无需在动画帧重复绘制。
修改后的完整代码
HTML(无变动)
<!DOCTYPE html> <html lang="pt-BR"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Rotas de Pontos</title> <style> body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; } #canvas-container { width: 600px; height: 600px; position: relative; } canvas { background-color: white; } </style> </head> <body> <div id="canvas-container"> <canvas id="canvas" width="600" height="600"></canvas> </div> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> </body> </html>
JavaScript
$(document).ready(function () { const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const points = []; const routes = []; const pointRadius = 10; const gridSize = 60; const numRows = Math.floor(canvas.width / gridSize); const numCols = Math.floor(canvas.height / gridSize); const animationDuration = 2000; // 绘制单个点 function drawPoint(x, y, color = 'black') { ctx.beginPath(); ctx.arc(x, y, pointRadius, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = color; ctx.stroke(); } // 计算二次贝塞尔曲线上的点 function getQuadraticPoint(t, p0, p1, p2) { const x = (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x; const y = (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y; return { x, y }; } // 绘制进度对应的曲线段 function drawCurveSegment(pointA, pointB, colorStart, colorEnd, progress) { const ctrlPoint = { x: (pointA.x + pointB.x) / 2, y: (pointA.y + pointB.y) / 2 - 50 }; // 创建渐变(基于完整路径的渐变) const gradient = ctx.createLinearGradient(pointA.x, pointA.y, pointB.x, pointB.y); gradient.addColorStop(0, colorStart); gradient.addColorStop(1, colorEnd); ctx.beginPath(); ctx.moveTo(pointA.x, pointA.y); // 绘制从起点到当前进度点的曲线 const currentPoint = getQuadraticPoint(progress, pointA, ctrlPoint, pointB); // 计算当前段的临时控制点:从起点到原控制点的进度点 const tempCtrl = getQuadraticPoint(progress, pointA, ctrlPoint, ctrlPoint); ctx.quadraticCurveTo(tempCtrl.x, tempCtrl.y, currentPoint.x, currentPoint.y); ctx.strokeStyle = gradient; ctx.lineWidth = 2; ctx.stroke(); } // 生成网格点 for (let i = 0; i < numRows; i++) { for (let j = 0; j < numCols; j++) { const x = i * gridSize + gridSize / 2; const y = j * gridSize + gridSize / 2; points.push({ x, y }); drawPoint(x, y); } } // 生成随机路线,同时初始化每个路线的动画状态 function generateRandomRoutes() { for (let i = 0; i < 20; i++) { const startPoint = points[Math.floor(Math.random() * points.length)]; const endPoint = points[Math.floor(Math.random() * points.length)]; routes.push({ startPoint, endPoint, startTime: performance.now(), colorStart: 'blue', colorEnd: 'red' }); } } generateRandomRoutes(); // 全局动画循环 function animateLoop() { // 清除画布,重新绘制所有已完成/进行中的路线 ctx.clearRect(0, 0, canvas.width, canvas.height); // 重新绘制静态网格点 points.forEach(p => drawPoint(p.x, p.y)); let hasActiveAnimations = false; routes.forEach(route => { const elapsed = performance.now() - route.startTime; const progress = Math.min(elapsed / animationDuration, 1); if (progress > 0) { drawCurveSegment(route.startPoint, route.endPoint, route.colorStart, route.colorEnd, progress); // 绘制起点和终点的标记 drawPoint(route.startPoint.x, route.startPoint.y, route.colorStart); drawPoint(route.endPoint.x, route.endPoint.y, route.colorEnd); } if (progress < 1) { hasActiveAnimations = true; } }); if (hasActiveAnimations) { requestAnimationFrame(animateLoop); } } // 启动动画 requestAnimationFrame(animateLoop); });
优化说明
- 全局动画循环统一管理所有路线进度,避免多个
requestAnimationFrame的性能开销。 - 通过二次贝塞尔公式直接计算当前进度的点和临时控制点,实现真正的平滑逐步绘制。
- 每次循环清除画布后重绘所有内容,避免裁剪残留,保证路线叠加显示正常。
内容的提问来源于stack exchange,提问作者Jonny
相关产品推荐
相关产品推荐

