Skip to content

变换(平移/旋转/缩放)

Canvas 提供了完整的 2D 仿射变换系统,包括平移(translate)、旋转(rotate)、缩放(scale)以及自定义变换矩阵。变换是 Canvas 实现复杂图形布局的核心机制,结合 save()/restore() 可以构建出层次分明的图形场景。本节将详细介绍 Canvas 变换系统的使用方法。

前置知识

阅读本节前,建议先了解:图像操作

基础概念

Canvas 的变换系统基于 2D 仿射变换矩阵。所有变换操作都是对当前变换矩阵(CTM)进行乘法运算。变换会影响后续所有绘图操作,包括坐标、形状和图像。

变换矩阵原理

Canvas 内部使用 3x3 的变换矩阵:

| a  c  e |   | x |   | ax + cy + e |
| b  d  f | x | y | = | bx + dy + f |
| 0  0  1 |   | 1 |   |      1      |

其中 a, b, c, d, e, f 是变换参数,可以通过 setTransform()transform() 直接设置。

translate() 平移

translate(tx, ty) 将坐标系原点移动到 (tx, ty) 位置。

javascript
ctx.translate(100, 50); // 将原点移动到 (100, 50)

// 现在 (0, 0) 对应画布上的 (100, 50)
ctx.fillRect(0, 0, 50, 50); // 绘制在 (100, 50) 处

平移的应用场景

javascript
// 1. 绘制多个相同图案
function drawPattern(ctx, x, y) {
  ctx.save();
  ctx.translate(x, y);
  ctx.fillStyle = '#4285f4';
  ctx.fillRect(-25, -25, 50, 50);
  ctx.restore();
}

drawPattern(ctx, 100, 100);
drawPattern(ctx, 200, 100);
drawPattern(ctx, 150, 200);

// 2. 以图形中心为原点绘制(简化计算)
ctx.translate(250, 250); // 移到画布中心
ctx.fillRect(-50, -50, 100, 100); // 以中心为原点

rotate() 旋转

rotate(angle) 围绕当前原点旋转坐标系,角度单位为弧度。

javascript
ctx.rotate(Math.PI / 4); // 顺时针旋转 45 度
ctx.fillRect(50, 0, 100, 50);

旋转的应用

javascript
// 围绕图形中心旋转
function drawRotatedRect(ctx, cx, cy, width, height, angle) {
  ctx.save();
  ctx.translate(cx, cy);     // 移到中心
  ctx.rotate(angle);         // 旋转
  ctx.fillRect(-width / 2, -height / 2, width, height); // 以中心绘制
  ctx.restore();
}

// 使用
drawRotatedRect(ctx, 200, 200, 100, 60, Math.PI / 6);  // 旋转 30 度
drawRotatedRect(ctx, 200, 200, 100, 60, Math.PI / 3);  // 旋转 60 度

scale() 缩放

scale(sx, sy) 分别在 X 和 Y 方向缩放坐标系。

javascript
ctx.scale(2, 2);    // 放大 2 倍
ctx.scale(1, -1);   // Y 轴翻转
ctx.scale(-1, 1);   // X 轴翻转
ctx.scale(0.5, 0.5); // 缩小到一半

缩放的应用

javascript
// 1. 镜像翻转(水平)
ctx.save();
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(img, 0, 0); // 水平镜像
ctx.restore();

// 2. 绘制缩放的图形
const scales = [0.5, 0.75, 1.0, 1.25, 1.5];
scales.forEach((s, i) => {
  ctx.save();
  ctx.translate(50 + i * 100, 200);
  ctx.scale(s, s);
  ctx.fillStyle = '#4285f4';
  ctx.fillRect(-25, -25, 50, 50);
  ctx.restore();
});

setTransform() 与 transform()

setTransform()

setTransform(a, b, c, d, e, f) 直接设置变换矩阵(替换而非累加):

javascript
// 重置为单位矩阵(等同于 resetTransform)
ctx.setTransform(1, 0, 0, 1, 0, 0);

// 设置自定义变换
ctx.setTransform(2, 0.5, -0.5, 2, 100, 100);

transform()

transform(a, b, c, d, e, f) 将新矩阵与当前矩阵相乘(累加变换):

javascript
ctx.translate(100, 100);  // 先平移
ctx.transform(2, 0, 0, 2, 0, 0);  // 再放大 2 倍

setTransform 与 transform 对比

方法行为类比
setTransform()替换当前矩阵ctx = newMatrix
transform()与当前矩阵相乘ctx = ctx * newMatrix
resetTransform()重置为单位矩阵ctx = identity

变换方向

Canvas 变换遵循"向右累加"规则:先调用的变换后执行(从右到左应用)。

javascript
// 组合变换:先旋转再平移
ctx.translate(200, 200);  // 第二步:平移
ctx.rotate(Math.PI / 4);  // 第一步:旋转

// 等价于:
ctx.translate(200, 200).rotate(Math.PI / 4)
// 图形先在自己的坐标系中旋转,然后平移到 (200, 200)

实战示例

示例 1:变换操作综合演示

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Canvas 变换</title>
  <style>
    canvas { border: 1px solid #ccc; display: block; margin: 10px 0; }
  </style>
</head>
<body>
  <h1>Canvas 变换操作</h1>
  <canvas id="transformCanvas" width="600" height="500"></canvas>

  <script>
    const canvas = document.getElementById('transformCanvas');
    const ctx = canvas.getContext('2d');

    // 辅助函数:绘制坐标轴
    function drawAxes(ctx, length, color) {
      ctx.strokeStyle = color || '#999';
      ctx.lineWidth = 1;
      // X 轴
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(length, 0);
      ctx.stroke();
      // Y 轴
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(0, length);
      ctx.stroke();
      // 原点
      ctx.fillStyle = '#ea4335';
      ctx.beginPath();
      ctx.arc(0, 0, 3, 0, Math.PI * 2);
      ctx.fill();
    }

    // === 1. 平移演示 ===
    ctx.fillStyle = '#333';
    ctx.font = 'bold 14px sans-serif';
    ctx.fillText('1. 平移', 20, 20);

    ctx.save();
    ctx.translate(80, 80);
    drawAxes(ctx, 40);
    ctx.fillStyle = '#4285f4';
    ctx.fillRect(0, 0, 30, 30);
    ctx.restore();

    // === 2. 旋转演示 ===
    ctx.fillText('2. 旋转', 200, 20);

    ctx.save();
    ctx.translate(280, 80);
    drawAxes(ctx, 40);
    ctx.rotate(Math.PI / 6); // 旋转 30 度
    ctx.fillStyle = '#34a853';
    ctx.fillRect(0, 0, 30, 30);
    ctx.restore();

    // === 3. 缩放演示 ===
    ctx.fillText('3. 缩放', 420, 20);

    ctx.save();
    ctx.translate(500, 80);
    drawAxes(ctx, 40);
    ctx.scale(1.5, 0.8);
    ctx.fillStyle = '#ea4335';
    ctx.fillRect(0, 0, 30, 30);
    ctx.restore();

    // === 4. 组合变换:绘制花朵 ===
    ctx.fillText('4. 组合变换(花朵)', 20, 170);

    ctx.save();
    ctx.translate(120, 280);

    for (let i = 0; i < 12; i++) {
      ctx.rotate(Math.PI / 6);
      ctx.fillStyle = `hsla(${i * 30}, 70%, 60%, 0.6)`;
      ctx.beginPath();
      ctx.ellipse(0, -30, 15, 30, 0, 0, Math.PI * 2);
      ctx.fill();
    }

    // 花心
    ctx.beginPath();
    ctx.arc(0, 0, 12, 0, Math.PI * 2);
    ctx.fillStyle = '#fbbc05';
    ctx.fill();
    ctx.restore();

    // === 5. Y 轴翻转镜像 ===
    ctx.fillText('5. 镜像翻转', 250, 170);

    // 原始图形
    ctx.save();
    ctx.translate(320, 230);
    drawAxes(ctx, 30);
    ctx.fillStyle = '#4285f4';
    ctx.fillRect(-15, -15, 30, 30);
    ctx.restore();

    // 镜像图形
    ctx.save();
    ctx.translate(380, 230);
    drawAxes(ctx, 30);
    ctx.scale(-1, 1);
    ctx.fillStyle = '#ea4335';
    ctx.fillRect(-15, -15, 30, 30);
    ctx.restore();

    // 标签
    ctx.fillStyle = '#333';
    ctx.font = '12px sans-serif';
    ctx.fillText('原始', 305, 280);
    ctx.fillText('镜像', 365, 280);

    // === 6. 变换矩阵动画提示 ===
    ctx.fillStyle = '#333';
    ctx.font = 'bold 14px sans-serif';
    ctx.fillText('6. 动态旋转方块', 470, 170);

    ctx.save();
    ctx.translate(550, 280);
    const time = Date.now() / 1000;
    for (let i = 0; i < 6; i++) {
      ctx.save();
      ctx.rotate(time + i * Math.PI / 3);
      ctx.fillStyle = `hsla(${i * 60}, 60%, 50%, 0.4)`;
      ctx.fillRect(-40, -10, 80, 20);
      ctx.restore();
    }
    ctx.restore();
  </script>
</body>
</html>

示例 2:时钟绘制

javascript
/**
 * 使用变换绘制时钟
 */
function drawClock(ctx, cx, cy, radius) {
  const now = new Date();
  const hours = now.getHours() % 12;
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();
  const millis = now.getMilliseconds();

  // 表盘
  ctx.beginPath();
  ctx.arc(cx, cy, radius, 0, Math.PI * 2);
  ctx.fillStyle = '#ffffff';
  ctx.fill();
  ctx.strokeStyle = '#333';
  ctx.lineWidth = 3;
  ctx.stroke();

  // 刻度
  ctx.save();
  ctx.translate(cx, cy);
  for (let i = 0; i < 60; i++) {
    ctx.rotate(Math.PI / 30);
    ctx.beginPath();
    if (i % 5 === 0) {
      ctx.moveTo(0, radius - 15);
      ctx.lineTo(0, radius - 5);
      ctx.lineWidth = 2;
    } else {
      ctx.moveTo(0, radius - 8);
      ctx.lineTo(0, radius - 5);
      ctx.lineWidth = 1;
    }
    ctx.stroke();
  }
  ctx.restore();

  // 时针
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate((hours + minutes / 60) * Math.PI / 6);
  ctx.beginPath();
  ctx.moveTo(0, 10);
  ctx.lineTo(0, -radius * 0.5);
  ctx.lineWidth = 4;
  ctx.strokeStyle = '#333';
  ctx.stroke();
  ctx.restore();

  // 分针
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate((minutes + seconds / 60) * Math.PI / 30);
  ctx.beginPath();
  ctx.moveTo(0, 10);
  ctx.lineTo(0, -radius * 0.7);
  ctx.lineWidth = 2;
  ctx.strokeStyle = '#333';
  ctx.stroke();
  ctx.restore();

  // 秒针
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate((seconds + millis / 1000) * Math.PI / 30);
  ctx.beginPath();
  ctx.moveTo(0, 15);
  ctx.lineTo(0, -radius * 0.85);
  ctx.lineWidth = 1;
  ctx.strokeStyle = '#ea4335';
  ctx.stroke();
  ctx.restore();

  // 中心点
  ctx.beginPath();
  ctx.arc(cx, cy, 4, 0, Math.PI * 2);
  ctx.fillStyle = '#ea4335';
  ctx.fill();
}

// 使用
const canvas = document.getElementById('clockCanvas');
const ctx = canvas.getContext('2d');

function updateClock() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawClock(ctx, 200, 200, 150);
  requestAnimationFrame(updateClock);
}
updateClock();

示例 3:变换矩阵分解

javascript
/**
 * 从变换矩阵中提取分解参数
 * @param {DOMMatrix} matrix
 * @returns {{ translateX, translateY, scaleX, scaleY, rotation, skewX, skewY }}
 */
function decomposeMatrix(matrix) {
  return {
    translateX: matrix.m41,
    translateY: matrix.m42,
    scaleX: matrix.m11,
    scaleY: matrix.m22,
    rotation: Math.atan2(matrix.m12, matrix.m11),
    skewX: Math.atan2(matrix.m21, matrix.m11),
    skewY: Math.atan2(matrix.m12, matrix.m22)
  };
}

// 使用
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);
ctx.scale(2, 1);

const matrix = ctx.getTransform();
const decomposed = decomposeMatrix(matrix);
console.log(decomposed);

注意事项

1. 变换是累加的

javascript
// 错误:变换累积
for (let i = 0; i < 10; i++) {
  ctx.rotate(Math.PI / 6); // 每次都累加旋转!
  ctx.fillRect(0, 0, 50, 50);
}

// 正确:使用 save/restore 或 setTransform
for (let i = 0; i < 10; i++) {
  ctx.save();
  ctx.rotate(i * Math.PI / 6);
  ctx.fillRect(0, 0, 50, 50);
  ctx.restore();
}

2. 缩放影响所有属性

javascript
ctx.scale(2, 2);
ctx.lineWidth = 1; // 实际渲染为 2px
ctx.font = '16px sans-serif'; // 实际渲染为 32px

3. 旋转中心

旋转总是围绕当前原点进行,不是围绕图形中心:

javascript
// 错误:围绕 (0,0) 旋转,图形会"飞走"
ctx.rotate(Math.PI / 4);
ctx.fillRect(200, 200, 50, 50);

// 正确:先移到图形中心,旋转,再绘制
ctx.translate(225, 225);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-25, -25, 50, 50);

最佳实践

1. 变换模式封装

javascript
/**
 * 在指定位置、旋转和缩放下执行绘制
 */
function drawAt(ctx, x, y, rotation, scaleX, scaleY, drawFn) {
  ctx.save();
  ctx.translate(x, y);
  if (rotation) ctx.rotate(rotation);
  if (scaleX || scaleY) ctx.scale(scaleX || 1, scaleY || 1);
  drawFn();
  ctx.restore();
}

// 使用
drawAt(ctx, 200, 200, Math.PI / 4, 1, 1, () => {
  ctx.fillStyle = '#4285f4';
  ctx.fillRect(-25, -25, 50, 50);
});

2. 使用 DOMMatrix 进行复杂变换

javascript
// DOMMatrix 提供更直观的变换方式
const matrix = new DOMMatrix();
matrix.translateSelf(200, 200);
matrix.rotateSelf(45);
matrix.scaleSelf(1.5);

// 应用到 Canvas
ctx.setTransform(matrix);

// 或链式调用
ctx.setTransform(
  new DOMMatrix()
    .translateSelf(200, 200)
    .rotateSelf(45)
    .scaleSelf(1.5)
);

3. 变换组合模式表

效果变换顺序代码
绕自身中心旋转translate -> rotate -> draw at origin见上例
沿路径放置translate -> rotate -> draw时钟刻度
缩放后居中translate -> scale -> draw at origin同上
斜切效果setTransform(1, skewX, skewY, 1, 0, 0)平行四边形

下一节

继续学习:合成与裁剪

参考链接