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

在前端实现复杂动画,特别是需要高性能和流畅度的场景,使用 canvas 配合 drawImagerequestAnimationFrame (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,使其在 0SPRITESHEET_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 使用率和内存占用,找出性能瓶颈。

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

相关推荐
10年前端老司机15 分钟前
10道js经典面试题助你找到好工作
前端·javascript
小小小小宇6 小时前
TS泛型笔记
前端
codingandsleeping6 小时前
重读《你不知道的JavaScript》(上)- 作用域和闭包
前端·javascript
小小小小宇6 小时前
前端PerformanceObserver使用
前端
zhangxingchao7 小时前
Flutter中的页面跳转
前端
烛阴8 小时前
Puppeteer入门指南:掌控浏览器,开启自动化新时代
前端·javascript
全宝9 小时前
🖲️一行代码实现鼠标换肤
前端·css·html
小小小小宇9 小时前
前端模拟一个setTimeout
前端
萌萌哒草头将军9 小时前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js