You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 06:27:02