Skip to content

第5章:变换与坐标系统

5.1 章节概述

在前面的章节中,我们学习了如何绑制各种图形和添加样式。但你可能已经发现一个问题:每次绘制图形时,我们都需要精确计算每个点的坐标。如果要绘制一个旋转 45 度的矩形,手动计算四个顶点的坐标会非常复杂。

变换(Transform) 是解决这个问题的利器。通过变换,我们可以移动、旋转、缩放整个坐标系统,然后在新的坐标系中简单地绘制图形。

本章将深入讲解:

  • 平移变换:移动坐标系原点
  • 旋转变换:围绕原点旋转坐标系
  • 缩放变换:放大或缩小坐标系
  • 变换矩阵:理解底层的数学原理
  • 变换组合:多个变换的叠加效果
  • 坐标转换:在不同坐标系之间转换

学完本章后,你将能够轻松创建复杂的图形变换效果,为动画和交互打下坚实基础。


5.2 理解变换的本质

5.2.1 变换是什么?

在 Canvas 中,变换不是改变图形本身,而是改变坐标系统

让我们用一个生活中的例子来理解:

想象你在一张透明纸上画画

  1. 透明纸放在桌子上,你画了一个在 (100, 100) 位置的圆
  2. 现在你把透明纸往右移动 50 像素
  3. 再画一个在 (100, 100) 位置的圆
  4. 结果:第二个圆实际上在桌子上的 (150, 100) 位置

这就是平移变换的本质:你没有改变绘图坐标,而是移动了整个"画布"。

变换前:                    变换后(translate(50, 0)):

(0,0)                      (0,0)
  ┌──────────────┐           ├──────────────┐
  │              │           │   (0,0)       │
  │   ●(100,100) │    →      │     ┌─────────│────┐
  │              │           │     │  ●      │    │
  └──────────────┘           └─────│─────────┘    │
  原始坐标系                       │  (100,100)    │
                                   └──────────────┘
                                   移动后的坐标系

5.2.2 变换的累积性

Canvas 的变换是累积的——每次变换都基于当前的坐标系状态,而不是初始状态:

javascript
// 初始状态:坐标系原点在 (0, 0)

ctx.translate(100, 0);   // 原点移到 (100, 0)
ctx.translate(50, 0);    // 原点再移到 (150, 0),不是 (50, 0)!

// 现在 ctx.fillRect(0, 0, 50, 50) 实际绘制在 (150, 0) 位置

这种累积性非常重要,理解它是掌握变换的关键。

5.2.3 变换的作用范围

变换只影响之后的绑制操作,不会改变已经绘制的内容:

javascript
// 绘制第一个矩形(不受变换影响)
ctx.fillRect(50, 50, 100, 100);

// 应用变换
ctx.translate(200, 0);

// 绘制第二个矩形(受变换影响)
ctx.fillRect(50, 50, 100, 100);  // 实际在 (250, 50)

// 第一个矩形仍然在 (50, 50),没有被移动

5.2.4 使用 save/restore 管理变换

因为变换是累积的,所以管理变换状态非常重要。save()restore() 是你的好帮手:

javascript
// 保存当前状态(包括变换)
ctx.save();

// 应用变换
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);

// 绘制
ctx.fillRect(-50, -50, 100, 100);

// 恢复状态,变换被重置
ctx.restore();

// 现在坐标系恢复到 save() 时的状态

最佳实践:每次需要临时变换时,都用 save()/restore() 包裹:

javascript
function drawRotatedRect(ctx, x, y, width, height, angle) {
    ctx.save();
    
    ctx.translate(x + width / 2, y + height / 2);  // 移动到中心
    ctx.rotate(angle);                               // 旋转
    ctx.fillRect(-width / 2, -height / 2, width, height);  // 绘制
    
    ctx.restore();
    // 变换已恢复,不影响后续绑制
}

5.3 平移变换(Translate)

5.3.1 translate 方法

translate(x, y) 将坐标系原点移动到指定位置:

javascript
ctx.translate(dx, dy);
参数含义
dx水平方向移动距离(正值向右,负值向左)
dy垂直方向移动距离(正值向下,负值向上)

5.3.2 基础示例

javascript
// 初始状态:在原点绘制红色矩形
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 50, 50);

// 平移坐标系
ctx.translate(100, 50);

// 在"新原点"绘制蓝色矩形
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 50, 50);  // 实际位置:(100, 50)

// 再次平移(基于当前位置)
ctx.translate(100, 50);

// 绘制绿色矩形
ctx.fillStyle = 'green';
ctx.fillRect(0, 0, 50, 50);  // 实际位置:(200, 100)

图解

(0,0) 红色              translate(100,50)
  ■                           ↓
                        (100,50) 蓝色

                                    translate(100,50)

                                    (200,100) 绿色

5.3.3 平移的实际应用

应用1:简化坐标计算

javascript
// 不使用平移:需要计算每个点的绝对坐标
function drawHouseAbsolute(ctx, x, y, size) {
    // 房子主体
    ctx.fillRect(x, y + size * 0.3, size, size * 0.7);
    
    // 屋顶
    ctx.beginPath();
    ctx.moveTo(x, y + size * 0.3);
    ctx.lineTo(x + size / 2, y);
    ctx.lineTo(x + size, y + size * 0.3);
    ctx.fill();
    
    // 门
    ctx.fillStyle = 'brown';
    ctx.fillRect(x + size * 0.4, y + size * 0.6, size * 0.2, size * 0.4);
}

// 使用平移:坐标更简单直观
function drawHouse(ctx, x, y, size) {
    ctx.save();
    ctx.translate(x, y);  // 移动到房子位置
    
    // 现在所有坐标都相对于 (0, 0)
    ctx.fillStyle = '#DEB887';
    ctx.fillRect(0, size * 0.3, size, size * 0.7);
    
    ctx.fillStyle = '#8B4513';
    ctx.beginPath();
    ctx.moveTo(0, size * 0.3);
    ctx.lineTo(size / 2, 0);
    ctx.lineTo(size, size * 0.3);
    ctx.fill();
    
    ctx.fillStyle = '#654321';
    ctx.fillRect(size * 0.4, size * 0.6, size * 0.2, size * 0.4);
    
    ctx.restore();
}

// 绘制多个房子
drawHouse(ctx, 50, 100, 80);
drawHouse(ctx, 200, 100, 80);
drawHouse(ctx, 350, 100, 80);

应用2:创建网格布局

javascript
function drawGrid(ctx, items, cols, cellWidth, cellHeight, gap) {
    items.forEach((item, index) => {
        const col = index % cols;
        const row = Math.floor(index / cols);
        
        ctx.save();
        ctx.translate(
            col * (cellWidth + gap),
            row * (cellHeight + gap)
        );
        
        // 在 (0, 0) 绘制每个 item
        drawItem(ctx, item, cellWidth, cellHeight);
        
        ctx.restore();
    });
}

5.4 旋转变换(Rotate)

5.4.1 rotate 方法

rotate(angle) 围绕坐标系原点旋转:

javascript
ctx.rotate(angle);  // angle 是弧度,不是角度!

重要:旋转是围绕当前原点进行的,默认是 Canvas 左上角 (0, 0)。

5.4.2 角度与弧度

Canvas 使用弧度而非角度。记住转换公式:

javascript
// 角度转弧度
const radians = degrees * Math.PI / 180;

// 弧度转角度
const degrees = radians * 180 / Math.PI;

// 常用角度
const deg45 = Math.PI / 4;      // 45度
const deg90 = Math.PI / 2;      // 90度
const deg180 = Math.PI;         // 180度
const deg360 = Math.PI * 2;     // 360度

5.4.3 旋转方向

Canvas 的旋转是顺时针方向(因为 Y 轴向下):

正角度 → 顺时针旋转
负角度 → 逆时针旋转



-90° ───┼─── 90°

       180°

5.4.4 围绕原点旋转

最简单的旋转——围绕 Canvas 原点 (0, 0):

javascript
// 在原点绘制一个矩形
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillRect(0, 0, 100, 50);

// 旋转 45 度
ctx.rotate(Math.PI / 4);

// 绘制旋转后的矩形
ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
ctx.fillRect(0, 0, 100, 50);

图解

原点 (0,0) 旋转:

  │    ╱╲
  │   ╱  ╲  旋转后的矩形
  │  ╱    ╲
  │ ╱──────╲
──┼────────────
  │ 原始矩形
  │ ▀▀▀▀▀▀▀▀

  
矩形左上角始终在原点,整个矩形围绕原点旋转

5.4.5 围绕图形中心旋转

通常我们想让图形围绕自己的中心旋转,需要结合平移:

javascript
/**
 * 绘制围绕中心旋转的矩形
 */
function drawRotatedRectFromCenter(ctx, cx, cy, width, height, angle) {
    ctx.save();
    
    // 1. 移动到矩形中心
    ctx.translate(cx, cy);
    
    // 2. 旋转(现在围绕矩形中心)
    ctx.rotate(angle);
    
    // 3. 绘制矩形(相对于中心点)
    ctx.fillRect(-width / 2, -height / 2, width, height);
    
    ctx.restore();
}

// 使用
drawRotatedRectFromCenter(ctx, 200, 200, 100, 60, Math.PI / 6);

旋转过程图解

步骤 1: translate(cx, cy)
将原点移到矩形中心位置

     原始原点(0,0)

        ●──────────────────→


        │    ● (cx, cy) 新原点



步骤 2: rotate(angle)
围绕新原点旋转坐标系



        ● ────╱
         ╲   ╱
          ╲ ╱


步骤 3: fillRect(-w/2, -h/2, w, h)
以中心为参考绘制矩形

           ╱─────────╲
          ╱     ●     ╲
         ╱   中心点    ╲
        ╱───────────────╲

5.4.6 旋转动画示例

javascript
class RotatingRect {
    constructor(ctx, x, y, width, height) {
        this.ctx = ctx;
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
        this.angle = 0;
        this.speed = 0.02;  // 每帧旋转的弧度
    }
    
    update() {
        this.angle += this.speed;
        if (this.angle > Math.PI * 2) {
            this.angle -= Math.PI * 2;
        }
    }
    
    draw() {
        const { ctx, x, y, width, height, angle } = this;
        
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(angle);
        
        ctx.fillStyle = '#4D7CFF';
        ctx.fillRect(-width / 2, -height / 2, width, height);
        
        ctx.restore();
    }
}

// 动画循环
const rect = new RotatingRect(ctx, 200, 200, 100, 60);

function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    rect.update();
    rect.draw();
    requestAnimationFrame(animate);
}

animate();

5.5 缩放变换(Scale)

5.5.1 scale 方法

scale(sx, sy) 缩放坐标系:

javascript
ctx.scale(sx, sy);
参数含义
sx水平方向缩放因子
sy垂直方向缩放因子

缩放因子的含义

效果
> 1放大
= 1不变
0 < x < 1缩小
< 0翻转(镜像)

5.5.2 基础缩放

javascript
// 原始大小
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillRect(50, 50, 100, 60);

// 放大 2 倍
ctx.scale(2, 2);
ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
ctx.fillRect(50, 50, 100, 60);  // 实际位置和大小都乘以 2

注意:缩放不仅影响图形大小,还影响位置和线宽!

javascript
ctx.scale(2, 2);
ctx.lineWidth = 1;  // 实际显示为 2px 的线宽
ctx.strokeRect(50, 50, 100, 60);  // 位置变成 (100, 100),大小变成 200×120

5.5.3 非等比缩放

javascript
// 只在水平方向放大
ctx.scale(2, 1);
ctx.fillRect(50, 50, 100, 100);  // 变成 200×100 的矩形

// 只在垂直方向缩小
ctx.scale(1, 0.5);
ctx.fillRect(50, 50, 100, 100);  // 变成 100×50 的矩形

5.5.4 镜像翻转

负的缩放因子可以实现镜像效果:

javascript
// 水平镜像
ctx.save();
ctx.translate(canvas.width, 0);  // 先移动到右边
ctx.scale(-1, 1);                 // 水平翻转
drawSomething(ctx);               // 绘制的内容会水平镜像
ctx.restore();

// 垂直镜像
ctx.save();
ctx.translate(0, canvas.height);  // 先移动到下边
ctx.scale(1, -1);                  // 垂直翻转
drawSomething(ctx);                // 绘制的内容会垂直镜像
ctx.restore();

// 中心点镜像
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(-1, -1);  // 点对称(旋转180度)
ctx.translate(-canvas.width / 2, -canvas.height / 2);
drawSomething(ctx);
ctx.restore();

镜像图示

原图          水平镜像(scale(-1,1))    垂直镜像(scale(1,-1))
  
 ▲              ▲                        ▼
╱ ╲            ╱ ╲                      ╱ ╲
────          ────                      ────

5.5.5 围绕中心点缩放

默认的缩放是围绕原点进行的。如果想围绕图形中心缩放:

javascript
function scaleFromCenter(ctx, cx, cy, scaleX, scaleY) {
    ctx.translate(cx, cy);       // 移到中心
    ctx.scale(scaleX, scaleY);   // 缩放
    ctx.translate(-cx, -cy);     // 移回去
}

// 使用
ctx.save();
scaleFromCenter(ctx, 200, 200, 2, 2);
ctx.fillRect(150, 150, 100, 100);  // 围绕 (200, 200) 放大
ctx.restore();

5.5.6 缩放动画(脉冲效果)

javascript
class PulsingCircle {
    constructor(ctx, x, y, baseRadius) {
        this.ctx = ctx;
        this.x = x;
        this.y = y;
        this.baseRadius = baseRadius;
        this.scale = 1;
        this.scaleDirection = 1;
        this.minScale = 0.8;
        this.maxScale = 1.2;
        this.speed = 0.02;
    }
    
    update() {
        this.scale += this.speed * this.scaleDirection;
        
        if (this.scale >= this.maxScale) {
            this.scaleDirection = -1;
        } else if (this.scale <= this.minScale) {
            this.scaleDirection = 1;
        }
    }
    
    draw() {
        const { ctx, x, y, baseRadius, scale } = this;
        
        ctx.save();
        ctx.translate(x, y);
        ctx.scale(scale, scale);
        
        ctx.beginPath();
        ctx.arc(0, 0, baseRadius, 0, Math.PI * 2);
        ctx.fillStyle = '#4D7CFF';
        ctx.fill();
        
        ctx.restore();
    }
}

5.6 变换的组合

5.6.1 变换顺序的重要性

变换的顺序非常重要! 不同的顺序会产生完全不同的结果:

javascript
// 顺序 1:先平移后旋转
ctx.save();
ctx.translate(200, 200);
ctx.rotate(Math.PI / 4);
ctx.fillRect(0, 0, 100, 50);
ctx.restore();

// 顺序 2:先旋转后平移
ctx.save();
ctx.rotate(Math.PI / 4);
ctx.translate(200, 200);
ctx.fillRect(0, 0, 100, 50);
ctx.restore();

// 两个矩形的位置完全不同!

理解变换顺序

可以把变换想象成一系列的坐标系变化。后面的变换是在前面变换的基础上进行的:

初始状态 → translate(200, 200) → rotate(45°) → fillRect(0, 0, 100, 50)

1. 原点在 (0, 0)
2. translate 后原点在 (200, 200)
3. rotate 后坐标系围绕 (200, 200) 旋转
4. fillRect 在旋转后的坐标系中绘制
初始状态 → rotate(45°) → translate(200, 200) → fillRect(0, 0, 100, 50)

1. 原点在 (0, 0)
2. rotate 后坐标系围绕 (0, 0) 旋转
3. translate 在旋转后的方向上移动 (200, 200)
   实际位置不是 (200, 200),而是沿旋转后的轴移动
4. fillRect 在最终坐标系中绘制

5.6.2 典型的变换组合模式

模式1:围绕中心点旋转

javascript
// 标准模式:translate → rotate → 绘制(相对于中心)
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillRect(-width/2, -height/2, width, height);

模式2:围绕中心点缩放

javascript
// 标准模式:translate → scale → translate 回来
ctx.translate(cx, cy);
ctx.scale(sx, sy);
ctx.translate(-cx, -cy);
// 然后正常绘制

模式3:复合变换(旋转 + 缩放 + 平移)

javascript
function drawTransformed(ctx, x, y, angle, scale, drawFunc) {
    ctx.save();
    
    ctx.translate(x, y);
    ctx.rotate(angle);
    ctx.scale(scale, scale);
    
    drawFunc(ctx);  // 在变换后的坐标系中绘制
    
    ctx.restore();
}

// 使用
drawTransformed(ctx, 200, 200, Math.PI / 6, 1.5, (ctx) => {
    ctx.fillRect(-50, -25, 100, 50);
});

5.6.3 变换顺序的可视化

javascript
// 创建可视化比较
function visualizeTransformOrder(ctx) {
    const scenarios = [
        {
            name: '先平移后旋转',
            transform: (ctx) => {
                ctx.translate(150, 150);
                ctx.rotate(Math.PI / 4);
            },
            color: '#FF6B6B'
        },
        {
            name: '先旋转后平移',
            transform: (ctx) => {
                ctx.rotate(Math.PI / 4);
                ctx.translate(150, 150);
            },
            color: '#4D7CFF'
        }
    ];
    
    // 绘制参考线
    ctx.strokeStyle = '#ccc';
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(400, 0);
    ctx.moveTo(0, 0);
    ctx.lineTo(0, 400);
    ctx.stroke();
    
    // 绘制原点标记
    ctx.fillStyle = '#333';
    ctx.beginPath();
    ctx.arc(0, 0, 5, 0, Math.PI * 2);
    ctx.fill();
    
    // 绘制两种变换的结果
    scenarios.forEach((scenario) => {
        ctx.save();
        scenario.transform(ctx);
        
        ctx.fillStyle = scenario.color;
        ctx.globalAlpha = 0.5;
        ctx.fillRect(0, 0, 100, 50);
        
        // 标记变换后的原点
        ctx.fillStyle = scenario.color;
        ctx.globalAlpha = 1;
        ctx.beginPath();
        ctx.arc(0, 0, 5, 0, Math.PI * 2);
        ctx.fill();
        
        ctx.restore();
    });
}

5.7 变换矩阵

5.7.1 什么是变换矩阵?

所有的 Canvas 变换(平移、旋转、缩放)在底层都是通过矩阵运算实现的。理解矩阵可以让你:

  1. 执行更复杂的变换
  2. 优化变换性能
  3. 实现自定义变换效果

Canvas 使用 3×3 变换矩阵,但由于第三行总是 [0, 0, 1],实际只需要 6 个参数:

┌         ┐     ┌         ┐
│ a  c  e │     │ a  c  e │
│ b  d  f │  →  │ b  d  f │  → 6 个参数
│ 0  0  1 │     └         ┘
└         ┘

5.7.2 transform 和 setTransform 方法

transform(a, b, c, d, e, f):将当前矩阵乘以指定矩阵

javascript
ctx.transform(a, b, c, d, e, f);

setTransform(a, b, c, d, e, f):重置矩阵并设置为指定值

javascript
ctx.setTransform(a, b, c, d, e, f);

参数含义

参数含义对应变换
a水平缩放scale(a, ?)
b垂直倾斜skew
c水平倾斜skew
d垂直缩放scale(?, d)
e水平平移translate(e, ?)
f垂直平移translate(?, f)

5.7.3 矩阵变换公式

点 (x, y) 经过变换后的新坐标 (x', y'):

x' = a*x + c*y + e
y' = b*x + d*y + f

5.7.4 常见变换的矩阵表示

单位矩阵(无变换)

javascript
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 等同于 resetTransform()

平移 translate(tx, ty)

javascript
ctx.transform(1, 0, 0, 1, tx, ty);
// 或
ctx.transform(
    1, 0,    // 不缩放
    0, 1,    // 不缩放
    tx, ty   // 平移
);

缩放 scale(sx, sy)

javascript
ctx.transform(sx, 0, 0, sy, 0, 0);
// 或
ctx.transform(
    sx, 0,   // 水平缩放
    0, sy,   // 垂直缩放
    0, 0     // 不平移
);

旋转 rotate(θ)

javascript
const cos = Math.cos(angle);
const sin = Math.sin(angle);
ctx.transform(cos, sin, -sin, cos, 0, 0);
// 或
ctx.transform(
    cos, sin,   // 旋转部分
    -sin, cos,  // 旋转部分
    0, 0        // 不平移
);

倾斜 skew(sx, sy)

Canvas 没有直接的 skew 方法,但可以用矩阵实现:

javascript
// 水平倾斜
ctx.transform(1, 0, Math.tan(angleX), 1, 0, 0);

// 垂直倾斜
ctx.transform(1, Math.tan(angleY), 0, 1, 0, 0);

5.7.5 矩阵运算示例

javascript
// 使用矩阵实现围绕中心点旋转
function rotateAroundCenter(ctx, cx, cy, angle) {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    
    // 组合变换矩阵:translate(cx,cy) * rotate(angle) * translate(-cx,-cy)
    ctx.transform(
        cos, sin,
        -sin, cos,
        cx - cx * cos + cy * sin,
        cy - cx * sin - cy * cos
    );
}

// 使用矩阵实现倾斜
function skewX(ctx, angle) {
    ctx.transform(1, 0, Math.tan(angle), 1, 0, 0);
}

function skewY(ctx, angle) {
    ctx.transform(1, Math.tan(angle), 0, 1, 0, 0);
}

// 倾斜示例
ctx.save();
skewX(ctx, Math.PI / 6);  // 水平倾斜 30 度
ctx.fillRect(100, 100, 100, 100);  // 绘制倾斜的矩形
ctx.restore();

5.7.6 getTransform 和 resetTransform

javascript
// 获取当前变换矩阵
const matrix = ctx.getTransform();
console.log(matrix);  // DOMMatrix 对象

// 重置变换矩阵
ctx.resetTransform();
// 等同于 ctx.setTransform(1, 0, 0, 1, 0, 0);

5.7.7 使用 DOMMatrix

现代浏览器提供了 DOMMatrix 类来处理矩阵运算:

javascript
// 创建矩阵
const matrix = new DOMMatrix();

// 链式变换
matrix.translateSelf(100, 50)
      .rotateSelf(45)
      .scaleSelf(2, 2);

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

// 矩阵运算
const m1 = new DOMMatrix().translateSelf(100, 100);
const m2 = new DOMMatrix().rotateSelf(45);
const combined = m1.multiply(m2);

// 逆矩阵
const inverse = matrix.inverse();

// 点变换
const point = new DOMPoint(50, 50);
const transformedPoint = point.matrixTransform(matrix);
console.log(transformedPoint.x, transformedPoint.y);

5.8 坐标转换

5.8.1 问题:鼠标坐标与 Canvas 坐标

当我们在 Canvas 上处理鼠标事件时,经常需要将鼠标坐标转换为 Canvas 坐标:

javascript
canvas.addEventListener('click', (e) => {
    // e.clientX, e.clientY 是相对于视口的坐标
    // 需要转换为 Canvas 坐标
    
    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    
    // 现在 (x, y) 是 Canvas 坐标
});

5.8.2 考虑变换的坐标转换

如果 Canvas 应用了变换,情况会变得复杂:

javascript
// 假设 Canvas 应用了以下变换
ctx.translate(100, 100);
ctx.scale(2, 2);
ctx.rotate(Math.PI / 4);

// 现在要将屏幕坐标转换为变换后的坐标系

解决方案:使用逆矩阵

javascript
class CoordinateTransformer {
    constructor(ctx) {
        this.ctx = ctx;
    }
    
    // 屏幕坐标 → Canvas 坐标(考虑变换)
    screenToCanvas(screenX, screenY) {
        const matrix = this.ctx.getTransform();
        const inverse = matrix.inverse();
        const point = new DOMPoint(screenX, screenY);
        const transformed = point.matrixTransform(inverse);
        return { x: transformed.x, y: transformed.y };
    }
    
    // Canvas 坐标 → 屏幕坐标
    canvasToScreen(canvasX, canvasY) {
        const matrix = this.ctx.getTransform();
        const point = new DOMPoint(canvasX, canvasY);
        const transformed = point.matrixTransform(matrix);
        return { x: transformed.x, y: transformed.y };
    }
}

// 使用
const transformer = new CoordinateTransformer(ctx);

canvas.addEventListener('click', (e) => {
    const rect = canvas.getBoundingClientRect();
    const screenX = e.clientX - rect.left;
    const screenY = e.clientY - rect.top;
    
    // 转换为变换后的坐标系
    const canvasPos = transformer.screenToCanvas(screenX, screenY);
    console.log('Canvas 坐标:', canvasPos.x, canvasPos.y);
});

5.8.3 高清屏的坐标转换

如果 Canvas 适配了高清屏(DPR),还需要额外处理:

javascript
class HDCanvasCoordinate {
    constructor(canvas, ctx) {
        this.canvas = canvas;
        this.ctx = ctx;
        this.dpr = window.devicePixelRatio || 1;
    }
    
    // 完整的屏幕到 Canvas 坐标转换
    screenToCanvas(e) {
        const rect = this.canvas.getBoundingClientRect();
        
        // 1. 相对于 Canvas 元素
        let x = e.clientX - rect.left;
        let y = e.clientY - rect.top;
        
        // 2. 考虑 CSS 尺寸与实际尺寸的差异
        x = x * (this.canvas.width / rect.width);
        y = y * (this.canvas.height / rect.height);
        
        // 3. 考虑 DPR 缩放
        x = x / this.dpr;
        y = y / this.dpr;
        
        // 4. 考虑变换矩阵
        const matrix = this.ctx.getTransform();
        // 注意:如果使用了 ctx.scale(dpr, dpr),矩阵已经包含了 DPR
        // 需要根据实际情况调整
        
        const inverse = matrix.inverse();
        const point = new DOMPoint(x * this.dpr, y * this.dpr);
        const transformed = point.matrixTransform(inverse);
        
        return {
            x: transformed.x,
            y: transformed.y
        };
    }
}

5.9 实战应用

5.9.1 创建可拖拽、可旋转的矩形

javascript
class TransformableRect {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
        this.rotation = 0;
        this.scaleX = 1;
        this.scaleY = 1;
        
        this.isDragging = false;
        this.dragOffset = { x: 0, y: 0 };
    }
    
    // 检测点是否在矩形内
    containsPoint(px, py) {
        // 将点转换到矩形的局部坐标系
        const dx = px - this.x;
        const dy = py - this.y;
        
        // 反向旋转
        const cos = Math.cos(-this.rotation);
        const sin = Math.sin(-this.rotation);
        const localX = dx * cos - dy * sin;
        const localY = dx * sin + dy * cos;
        
        // 反向缩放
        const unscaledX = localX / this.scaleX;
        const unscaledY = localY / this.scaleY;
        
        // 检测是否在矩形范围内
        return unscaledX >= -this.width / 2 &&
               unscaledX <= this.width / 2 &&
               unscaledY >= -this.height / 2 &&
               unscaledY <= this.height / 2;
    }
    
    draw(ctx) {
        ctx.save();
        
        // 应用变换
        ctx.translate(this.x, this.y);
        ctx.rotate(this.rotation);
        ctx.scale(this.scaleX, this.scaleY);
        
        // 绘制矩形
        ctx.fillStyle = this.isDragging ? '#FF6B6B' : '#4D7CFF';
        ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height);
        
        // 绘制中心点
        ctx.fillStyle = '#FFFFFF';
        ctx.beginPath();
        ctx.arc(0, 0, 4, 0, Math.PI * 2);
        ctx.fill();
        
        ctx.restore();
    }
    
    startDrag(px, py) {
        if (this.containsPoint(px, py)) {
            this.isDragging = true;
            this.dragOffset.x = px - this.x;
            this.dragOffset.y = py - this.y;
            return true;
        }
        return false;
    }
    
    drag(px, py) {
        if (this.isDragging) {
            this.x = px - this.dragOffset.x;
            this.y = py - this.dragOffset.y;
        }
    }
    
    endDrag() {
        this.isDragging = false;
    }
}

// 使用示例
const rect = new TransformableRect(200, 200, 100, 60);
rect.rotation = Math.PI / 6;

canvas.addEventListener('mousedown', (e) => {
    const pos = getCanvasPos(e);
    rect.startDrag(pos.x, pos.y);
});

canvas.addEventListener('mousemove', (e) => {
    const pos = getCanvasPos(e);
    rect.drag(pos.x, pos.y);
    redraw();
});

canvas.addEventListener('mouseup', () => {
    rect.endDrag();
    redraw();
});

function redraw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    rect.draw(ctx);
}

5.9.2 创建行星轨道动画

javascript
class Planet {
    constructor(orbitRadius, orbitSpeed, size, color) {
        this.orbitRadius = orbitRadius;
        this.orbitSpeed = orbitSpeed;
        this.size = size;
        this.color = color;
        this.angle = Math.random() * Math.PI * 2;
    }
    
    update() {
        this.angle += this.orbitSpeed;
    }
    
    draw(ctx) {
        ctx.save();
        
        // 围绕原点(太阳位置)旋转
        ctx.rotate(this.angle);
        
        // 移动到轨道位置
        ctx.translate(this.orbitRadius, 0);
        
        // 绘制行星
        ctx.beginPath();
        ctx.arc(0, 0, this.size, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
        
        ctx.restore();
    }
}

class SolarSystem {
    constructor(ctx, centerX, centerY) {
        this.ctx = ctx;
        this.centerX = centerX;
        this.centerY = centerY;
        
        this.planets = [
            new Planet(60, 0.03, 8, '#8B4513'),   // 水星
            new Planet(90, 0.02, 12, '#DEB887'),  // 金星
            new Planet(130, 0.015, 14, '#4169E1'), // 地球
            new Planet(180, 0.01, 10, '#CD5C5C'),  // 火星
            new Planet(250, 0.005, 25, '#DAA520'), // 木星
        ];
    }
    
    update() {
        this.planets.forEach(planet => planet.update());
    }
    
    draw() {
        const { ctx, centerX, centerY } = this;
        
        ctx.save();
        ctx.translate(centerX, centerY);
        
        // 绘制轨道
        this.planets.forEach(planet => {
            ctx.beginPath();
            ctx.arc(0, 0, planet.orbitRadius, 0, Math.PI * 2);
            ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
            ctx.stroke();
        });
        
        // 绘制太阳
        ctx.beginPath();
        ctx.arc(0, 0, 30, 0, Math.PI * 2);
        ctx.fillStyle = '#FFD700';
        ctx.fill();
        
        // 绘制行星
        this.planets.forEach(planet => planet.draw(ctx));
        
        ctx.restore();
    }
}

// 动画
const solarSystem = new SolarSystem(ctx, 400, 300);

function animate() {
    ctx.fillStyle = '#000033';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    solarSystem.update();
    solarSystem.draw();
    
    requestAnimationFrame(animate);
}

animate();

5.9.3 创建时钟

javascript
function drawClock(ctx, centerX, centerY, radius) {
    const now = new Date();
    const hours = now.getHours() % 12;
    const minutes = now.getMinutes();
    const seconds = now.getSeconds();
    
    ctx.save();
    ctx.translate(centerX, centerY);
    
    // 绘制表盘
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.fillStyle = '#FFFFFF';
    ctx.fill();
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 3;
    ctx.stroke();
    
    // 绘制刻度
    for (let i = 0; i < 12; i++) {
        ctx.save();
        ctx.rotate((i * 30) * Math.PI / 180);
        
        ctx.beginPath();
        ctx.moveTo(0, -radius + 15);
        ctx.lineTo(0, -radius + (i % 3 === 0 ? 30 : 20));
        ctx.strokeStyle = '#333';
        ctx.lineWidth = i % 3 === 0 ? 3 : 1;
        ctx.stroke();
        
        ctx.restore();
    }
    
    // 时针
    ctx.save();
    ctx.rotate((hours * 30 + minutes * 0.5) * Math.PI / 180);
    ctx.beginPath();
    ctx.moveTo(0, 10);
    ctx.lineTo(0, -radius * 0.5);
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 6;
    ctx.lineCap = 'round';
    ctx.stroke();
    ctx.restore();
    
    // 分针
    ctx.save();
    ctx.rotate((minutes * 6 + seconds * 0.1) * Math.PI / 180);
    ctx.beginPath();
    ctx.moveTo(0, 15);
    ctx.lineTo(0, -radius * 0.7);
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 4;
    ctx.lineCap = 'round';
    ctx.stroke();
    ctx.restore();
    
    // 秒针
    ctx.save();
    ctx.rotate(seconds * 6 * Math.PI / 180);
    ctx.beginPath();
    ctx.moveTo(0, 20);
    ctx.lineTo(0, -radius * 0.8);
    ctx.strokeStyle = '#FF0000';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.restore();
    
    // 中心点
    ctx.beginPath();
    ctx.arc(0, 0, 5, 0, Math.PI * 2);
    ctx.fillStyle = '#333';
    ctx.fill();
    
    ctx.restore();
}

// 动画
function animateClock() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawClock(ctx, 200, 200, 150);
    requestAnimationFrame(animateClock);
}

animateClock();

5.10 本章小结

本章深入介绍了 Canvas 的变换系统:

核心知识

变换类型方法作用
平移translate(x, y)移动坐标系原点
旋转rotate(angle)围绕原点旋转(弧度)
缩放scale(sx, sy)缩放坐标系
矩阵transform(a,b,c,d,e,f)自定义变换矩阵
重置setTransform() / resetTransform()重置变换

重要概念

概念说明
变换累积每次变换基于当前状态
变换顺序不同顺序产生不同结果
围绕中心translate → rotate/scale → 绘制(相对中心)
逆变换使用逆矩阵进行坐标转换

常用模式

javascript
// 围绕中心旋转
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillRect(-w/2, -h/2, w, h);

// 围绕中心缩放
ctx.translate(cx, cy);
ctx.scale(s, s);
ctx.translate(-cx, -cy);

5.11 练习题

基础练习

  1. 绘制一个围绕自身中心旋转的正方形动画

  2. 实现一个可以水平/垂直翻转的图像查看器

  3. 创建一个缩放动画,让图形从小变大再变小

进阶练习

  1. 实现一个简单的 2D 场景图:

    • 支持嵌套的父子关系
    • 子元素继承父元素的变换
  2. 创建一个可以拖拽、旋转、缩放的图形编辑器

挑战练习

  1. 实现一个完整的时钟,包括:
    • 时、分、秒针
    • 刻度和数字
    • 平滑的秒针移动

下一章预告:在第6章中,我们将学习图像与像素操作——如何加载、绘制和处理图像,以及像素级的图像操作。


文档版本:v1.0
字数统计:约 16,000 字
代码示例:45+ 个

如有转载或 CV 的请标注本站原文地址