前端canvas手动实现复杂动画示例

在前端实现复杂动画,特别是需要高性能和流畅度的场景,使用 canvas 配合 drawImage 和 requestAnimationFrame (rAF) 是一个非常强大的组合。这种方式将动画的每一帧都绘制在 canvas 上,而不是依赖 DOM 操作,从而避免了浏览器布局和重绘的开销,提供了更精细的控制。

核心概念

  1. requestAnimationFrame (rAF) :

    • 这是浏览器专门为动画提供的 API。它告诉浏览器你希望执行一个动画,并请求浏览器在下一次重绘之前调用你指定的回调函数。

    • 优势:

      • 与浏览器刷新率同步:确保动画在最佳时机执行,避免掉帧或过度渲染。
      • 节省电池 :当页面不在活动标签页时,浏览器会暂停 rAF 回调,从而节省 CPU 和电池。
      • 避免阻塞:它是一个异步操作,不会阻塞主线程。
  2. canvas 2D 上下文:

    • HTMLCanvasElement.getContext('2d') 返回一个 CanvasRenderingContext2D 对象,提供了在 canvas 上绘制图形、文本、图像的方法。
  3. drawImage() 方法:

    • 这是在 canvas 上绘制图像的关键方法。它有多种重载形式,可以满足不同的需求:

      • ctx.drawImage(image, dx, dy): 在 (dx, dy) 处绘制整个 image。
      • ctx.drawImage(image, dx, dy, dw, dh): 在 (dx, dy) 处绘制整个 image,并将其缩放到 (dw, dh) 的尺寸。
      • ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight): 这是最常用的形式,特别是用于精灵图(spritesheet)。它从 image 中裁剪出 (sx, sy) 处开始,尺寸为 (sWidth, sHeight) 的矩形区域(源矩形),然后将其绘制到 canvas 上 (dx, dy) 处开始,尺寸为 (dWidth, dHeight) 的矩形区域(目标矩形)。
  4. 图像预加载:

    • drawImage 只能绘制已经加载完成的图像。因此,在开始动画之前,必须确保所有用到的图像资源都已加载完毕。

示例场景:精灵图动画(人物行走)

我们将创建一个简单的动画,一个人物角色在 canvas 上水平移动,并使用精灵图来实现其行走动画。

文件结构:

markdown 复制代码
project/
├── index.html
├── style.css
└── script.js
└── assets/
    └── character_spritesheet.png  (假设这是你的精灵图)

character_spritesheet.png 示例:

假设你的精灵图是一行多帧的行走动画,每帧大小为 64x64 像素。

diff 复制代码
+----+----+----+----+----+----+
| F1 | F2 | F3 | F4 | F5 | F6 |  (F = Frame)
+----+----+----+----+----+----+

详细代码示例

index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas 精灵图动画</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Canvas 精灵图动画示例</h1>
    <canvas id="gameCanvas" width="800" height="400"></canvas>
    <script src="script.js"></script>
</body>
</html>

style.css (可选,用于美化)

css 复制代码
body {
    display: flex;
    flex-direction: column;
    align-items: center;
    font-family: sans-serif;
    background-color: #f0f0f0;
    margin-top: 20px;
}

canvas {
    border: 2px solid #333;
    background-color: #fff;
    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}

script.js

js 复制代码
// 1. 获取 Canvas 元素和 2D 上下文
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

// 2. 动画配置
const CHARACTER_WIDTH = 64;  // 精灵图中每一帧的宽度
const CHARACTER_HEIGHT = 64; // 精灵图中每一帧的高度
const SPRITESHEET_FRAMES = 6; // 精灵图总共有多少帧
const ANIMATION_SPEED = 100; // 每帧动画播放的毫秒数 (值越小,动画越快)
const MOVE_SPEED = 2;        // 角色每帧移动的像素数

let characterImage = new Image();
characterImage.src = './assets/character_spritesheet.png'; // 确保路径正确

let currentFrame = 0; // 当前播放的帧索引
let characterX = 0;   // 角色当前X坐标
let lastFrameTime = 0; // 上一帧动画更新的时间戳

// 3. 图像预加载
// 确保图像加载完成后再开始动画
characterImage.onload = () => {
    console.log('角色精灵图加载完成!');
    // 图像加载完成后,启动动画循环
    requestAnimationFrame(animate);
};

characterImage.onerror = () => {
    console.error('角色精灵图加载失败!请检查路径。');
};

// 4. 动画循环函数
function animate(currentTime) {
    // currentTime 是 requestAnimationFrame 提供的当前时间戳

    // 性能优化:清除画布
    // ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除整个画布
    // 或者只清除角色所在区域,但对于复杂场景,全清除更简单且通常性能影响不大
    // ctx.clearRect(characterX - MOVE_SPEED, 0, CHARACTER_WIDTH + MOVE_SPEED * 2, canvas.height);

    // 每次绘制前清除整个画布,确保没有残影
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // 更新动画帧 (基于时间,确保在不同帧率下动画速度一致)
    if (currentTime - lastFrameTime > ANIMATION_SPEED) {
        currentFrame = (currentFrame + 1) % SPRITESHEET_FRAMES;
        lastFrameTime = currentTime;
    }

    // 更新角色位置
    characterX += MOVE_SPEED;
    // 如果角色移出画布,则从左侧重新开始
    if (characterX > canvas.width) {
        characterX = -CHARACTER_WIDTH; // 从左侧完全移出再回来
    }

    // 绘制角色
    // 使用 drawImage 的 9 参数形式:
    // ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
    // image: 要绘制的图像对象
    // sx: 源图像中要裁剪的起始X坐标
    // sy: 源图像中要裁剪的起始Y坐标
    // sWidth: 源图像中要裁剪的宽度
    // sHeight: 源图像中要裁剪的高度
    // dx: 在 canvas 上绘制的起始X坐标
    // dy: 在 canvas 上绘制的起始Y坐标
    // dWidth: 在 canvas 上绘制的宽度 (可以缩放)
    // dHeight: 在 canvas 上绘制的高度 (可以缩放)

    const sourceX = currentFrame * CHARACTER_WIDTH; // 根据当前帧计算源X坐标
    const sourceY = 0; // 假设所有帧都在精灵图的第一行

    ctx.drawImage(
        characterImage,      // 图像源
        sourceX,             // 源X (精灵图中的裁剪X)
        sourceY,             // 源Y (精灵图中的裁剪Y)
        CHARACTER_WIDTH,     // 源宽度 (精灵图中的裁剪宽度)
        CHARACTER_HEIGHT,    // 源高度 (精灵图中的裁剪高度)
        characterX,          // 目标X (canvas上的绘制X)
        canvas.height - CHARACTER_HEIGHT, // 目标Y (让角色站在底部)
        CHARACTER_WIDTH,     // 目标宽度 (canvas上的绘制宽度)
        CHARACTER_HEIGHT     // 目标高度 (canvas上的绘制高度)
    );

    // 5. 循环调用 requestAnimationFrame
    // 请求浏览器在下一帧继续调用 animate 函数
    requestAnimationFrame(animate);
}

// 初始调用 (在图像加载完成后)
// 注意:首次调用 requestAnimationFrame 应该在图像加载完成后进行,
// 否则可能在图像未加载时就开始绘制,导致图像不显示。

代码讲解

  1. Canvas 和 Context 获取:

    • const canvas = document.getElementById('gameCanvas'); 和 const ctx = canvas.getContext('2d'); 是获取 Canvas 元素和其 2D 渲染上下文的标准步骤。所有绘制操作都通过 ctx 对象进行。
  2. 动画配置:

    • CHARACTER_WIDTH, CHARACTER_HEIGHT: 定义了精灵图中每一帧的尺寸。
    • SPRITESHEET_FRAMES: 精灵图包含的总帧数。
    • ANIMATION_SPEED: 控制精灵动画播放的速度。这里使用毫秒,表示每隔多少毫秒切换到下一帧。
    • MOVE_SPEED: 控制角色在 canvas 上每帧移动的像素数。
    • characterImage: 创建 Image 对象并设置 src 来加载精灵图。
  3. 图像预加载 (characterImage.onload) :

    • 这是非常关键的一步。drawImage 只能绘制已经加载完成的图像。
    • characterImage.onload 确保在图像完全加载到内存后才执行回调函数。
    • 在 onload 回调中,我们首次调用 requestAnimationFrame(animate) 来启动动画循环。如果图像加载失败 (onerror),则打印错误。
  4. 动画循环 (animate 函数) :

    • animate 函数是动画的核心,它会在每一帧被 requestAnimationFrame 调用。

    • currentTime 参数:requestAnimationFrame 会自动传入一个高精度的时间戳,表示当前帧的时间。我们用它来控制动画帧的切换,确保动画速度在不同设备和帧率下保持一致(基于时间而非帧数)。

    • ctx.clearRect(0, 0, canvas.width, canvas.height); :

      • 在绘制新一帧之前,必须清除上一帧的内容。否则,你会看到残影。清除整个画布是最简单且通常性能足够好的方法。对于非常复杂的场景,可以考虑只清除需要更新的区域。
    • 更新动画帧:

      • if (currentTime - lastFrameTime > ANIMATION_SPEED): 这是一个基于时间的帧切换逻辑。只有当距离上次帧更新的时间超过 ANIMATION_SPEED 时,才切换到下一帧。
      • currentFrame = (currentFrame + 1) % SPRITESHEET_FRAMES;: 循环切换 currentFrame,使其在 0 到 SPRITESHEET_FRAMES - 1 之间循环。
      • lastFrameTime = currentTime;: 更新上次帧更新的时间。
    • 更新角色位置:

      • characterX += MOVE_SPEED;: 简单地让角色向右移动。
      • if (characterX > canvas.width) { characterX = -CHARACTER_WIDTH; }: 当角色移出画布右侧时,将其重置到画布左侧外,实现循环移动。
    • 绘制角色 (ctx.drawImage) :

      • 这是最核心的绘制部分,使用了 drawImage 的 9 参数形式。
      • sourceX = currentFrame * CHARACTER_WIDTH;: 根据当前的 currentFrame 计算出要从精灵图中裁剪的起始 X 坐标。
      • sourceY = 0;: 假设精灵图是单行的,所以 Y 坐标始终为 0。
      • CHARACTER_WIDTH, CHARACTER_HEIGHT: 定义了从源图像中裁剪的区域大小。
      • characterX, canvas.height - CHARACTER_HEIGHT: 定义了在 canvas 上绘制的目标位置。canvas.height - CHARACTER_HEIGHT 让角色底部与 canvas 底部对齐。
      • 最后两个 CHARACTER_WIDTH, CHARACTER_HEIGHT: 定义了在 canvas 上绘制的尺寸。这里保持与源尺寸一致,也可以放大或缩小。
  5. 循环调用 requestAnimationFrame(animate) :

    • 在 animate 函数的末尾再次调用 requestAnimationFrame(animate),形成一个无限循环,确保动画持续进行。

性能优化建议

  1. 图像预加载:始终在动画开始前加载所有图像资源。

  2. 避免在循环中创建对象 :在 animate 循环中,避免创建新的对象(如 new Image()),这会增加垃圾回收的压力。

  3. clearRect 优化:

    • 如果场景复杂且只有小部分区域更新,可以考虑只清除并重绘受影响的区域,而不是整个画布。但对于大多数情况,全屏 clearRect 结合 rAF 已经足够高效。
    • 如果背景是静态的,可以将其绘制到一个单独的离屏 canvas 上,然后只在主 canvas 上绘制动态元素,或者只清除动态元素的区域。
  4. 离屏 Canvas (Offscreen Canvas) :

    • 对于非常复杂的图形计算或绘制,可以将这些操作放到 Web Worker 中,使用 OffscreenCanvas 进行渲染,然后将渲染结果传输到主线程的 canvas 上显示。这可以避免阻塞主线程。
  5. Batching Draw Calls (批量绘制) :

    • 如果有很多小图像要绘制,尝试将它们合并到一张大精灵图上,然后用 drawImage 裁剪绘制,这比多次调用 drawImage 绘制独立小图像性能更好。
  6. 避免浮点数像素:

    • 尽量使用整数像素坐标进行绘制 (dx, dy, dw, dh),因为子像素渲染可能会导致模糊或额外的计算开销。可以使用 Math.floor() 或 Math.round()。
  7. GPU 加速:

    • canvas 的绘制操作通常会利用 GPU 加速。确保你的绘制操作能被浏览器优化。
  8. 减少 DOM 操作:

    • canvas 动画的优势之一就是减少 DOM 操作。在动画循环中,除了 canvas 自身,尽量不要去操作其他 DOM 元素。
  9. 性能监控:

    • 使用浏览器开发者工具(如 Chrome DevTools 的 Performance 面板)来监控动画的帧率、CPU 使用率和内存占用,找出性能瓶颈。

通过以上方法,你可以使用 canvas 和 drawImage 结合 requestAnimationFrame 实现高性能、流畅的前端复杂动画。

相关推荐
卸任3 小时前
Vibe coding工具使用心得
前端·ai编程
brien94 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance4 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M4 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写4 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易5 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马5 小时前
前沿视界
前端
kyriewen5 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易5 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员