第5章:变换与坐标系统
5.1 章节概述
在前面的章节中,我们学习了如何绑制各种图形和添加样式。但你可能已经发现一个问题:每次绘制图形时,我们都需要精确计算每个点的坐标。如果要绘制一个旋转 45 度的矩形,手动计算四个顶点的坐标会非常复杂。
变换(Transform) 是解决这个问题的利器。通过变换,我们可以移动、旋转、缩放整个坐标系统,然后在新的坐标系中简单地绘制图形。
本章将深入讲解:
- 平移变换:移动坐标系原点
- 旋转变换:围绕原点旋转坐标系
- 缩放变换:放大或缩小坐标系
- 变换矩阵:理解底层的数学原理
- 变换组合:多个变换的叠加效果
- 坐标转换:在不同坐标系之间转换
学完本章后,你将能够轻松创建复杂的图形变换效果,为动画和交互打下坚实基础。
5.2 理解变换的本质
5.2.1 变换是什么?
在 Canvas 中,变换不是改变图形本身,而是改变坐标系统。
让我们用一个生活中的例子来理解:
想象你在一张透明纸上画画:
- 透明纸放在桌子上,你画了一个在 (100, 100) 位置的圆
- 现在你把透明纸往右移动 50 像素
- 再画一个在 (100, 100) 位置的圆
- 结果:第二个圆实际上在桌子上的 (150, 100) 位置
这就是平移变换的本质:你没有改变绘图坐标,而是移动了整个"画布"。
变换前: 变换后(translate(50, 0)):
(0,0) (0,0)
┌──────────────┐ ├──────────────┐
│ │ │ (0,0) │
│ ●(100,100) │ → │ ┌─────────│────┐
│ │ │ │ ● │ │
└──────────────┘ └─────│─────────┘ │
原始坐标系 │ (100,100) │
└──────────────┘
移动后的坐标系5.2.2 变换的累积性
Canvas 的变换是累积的——每次变换都基于当前的坐标系状态,而不是初始状态:
// 初始状态:坐标系原点在 (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 变换的作用范围
变换只影响之后的绑制操作,不会改变已经绘制的内容:
// 绘制第一个矩形(不受变换影响)
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() 是你的好帮手:
// 保存当前状态(包括变换)
ctx.save();
// 应用变换
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);
// 绘制
ctx.fillRect(-50, -50, 100, 100);
// 恢复状态,变换被重置
ctx.restore();
// 现在坐标系恢复到 save() 时的状态最佳实践:每次需要临时变换时,都用 save()/restore() 包裹:
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) 将坐标系原点移动到指定位置:
ctx.translate(dx, dy);| 参数 | 含义 |
|---|---|
| dx | 水平方向移动距离(正值向右,负值向左) |
| dy | 垂直方向移动距离(正值向下,负值向上) |
5.3.2 基础示例
// 初始状态:在原点绘制红色矩形
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:简化坐标计算
// 不使用平移:需要计算每个点的绝对坐标
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:创建网格布局
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) 围绕坐标系原点旋转:
ctx.rotate(angle); // angle 是弧度,不是角度!重要:旋转是围绕当前原点进行的,默认是 Canvas 左上角 (0, 0)。
5.4.2 角度与弧度
Canvas 使用弧度而非角度。记住转换公式:
// 角度转弧度
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 轴向下):
正角度 → 顺时针旋转
负角度 → 逆时针旋转
0°
│
-90° ───┼─── 90°
│
180°5.4.4 围绕原点旋转
最简单的旋转——围绕 Canvas 原点 (0, 0):
// 在原点绘制一个矩形
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 围绕图形中心旋转
通常我们想让图形围绕自己的中心旋转,需要结合平移:
/**
* 绘制围绕中心旋转的矩形
*/
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 旋转动画示例
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) 缩放坐标系:
ctx.scale(sx, sy);| 参数 | 含义 |
|---|---|
| sx | 水平方向缩放因子 |
| sy | 垂直方向缩放因子 |
缩放因子的含义:
| 值 | 效果 |
|---|---|
| > 1 | 放大 |
| = 1 | 不变 |
| 0 < x < 1 | 缩小 |
| < 0 | 翻转(镜像) |
5.5.2 基础缩放
// 原始大小
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注意:缩放不仅影响图形大小,还影响位置和线宽!
ctx.scale(2, 2);
ctx.lineWidth = 1; // 实际显示为 2px 的线宽
ctx.strokeRect(50, 50, 100, 60); // 位置变成 (100, 100),大小变成 200×1205.5.3 非等比缩放
// 只在水平方向放大
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 镜像翻转
负的缩放因子可以实现镜像效果:
// 水平镜像
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 围绕中心点缩放
默认的缩放是围绕原点进行的。如果想围绕图形中心缩放:
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 缩放动画(脉冲效果)
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 变换顺序的重要性
变换的顺序非常重要! 不同的顺序会产生完全不同的结果:
// 顺序 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:围绕中心点旋转
// 标准模式:translate → rotate → 绘制(相对于中心)
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillRect(-width/2, -height/2, width, height);模式2:围绕中心点缩放
// 标准模式:translate → scale → translate 回来
ctx.translate(cx, cy);
ctx.scale(sx, sy);
ctx.translate(-cx, -cy);
// 然后正常绘制模式3:复合变换(旋转 + 缩放 + 平移)
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 变换顺序的可视化
// 创建可视化比较
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 变换(平移、旋转、缩放)在底层都是通过矩阵运算实现的。理解矩阵可以让你:
- 执行更复杂的变换
- 优化变换性能
- 实现自定义变换效果
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):将当前矩阵乘以指定矩阵
ctx.transform(a, b, c, d, e, f);setTransform(a, b, c, d, e, f):重置矩阵并设置为指定值
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 + f5.7.4 常见变换的矩阵表示
单位矩阵(无变换):
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 等同于 resetTransform()平移 translate(tx, ty):
ctx.transform(1, 0, 0, 1, tx, ty);
// 或
ctx.transform(
1, 0, // 不缩放
0, 1, // 不缩放
tx, ty // 平移
);缩放 scale(sx, sy):
ctx.transform(sx, 0, 0, sy, 0, 0);
// 或
ctx.transform(
sx, 0, // 水平缩放
0, sy, // 垂直缩放
0, 0 // 不平移
);旋转 rotate(θ):
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 方法,但可以用矩阵实现:
// 水平倾斜
ctx.transform(1, 0, Math.tan(angleX), 1, 0, 0);
// 垂直倾斜
ctx.transform(1, Math.tan(angleY), 0, 1, 0, 0);5.7.5 矩阵运算示例
// 使用矩阵实现围绕中心点旋转
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
// 获取当前变换矩阵
const matrix = ctx.getTransform();
console.log(matrix); // DOMMatrix 对象
// 重置变换矩阵
ctx.resetTransform();
// 等同于 ctx.setTransform(1, 0, 0, 1, 0, 0);5.7.7 使用 DOMMatrix
现代浏览器提供了 DOMMatrix 类来处理矩阵运算:
// 创建矩阵
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 坐标:
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 应用了变换,情况会变得复杂:
// 假设 Canvas 应用了以下变换
ctx.translate(100, 100);
ctx.scale(2, 2);
ctx.rotate(Math.PI / 4);
// 现在要将屏幕坐标转换为变换后的坐标系解决方案:使用逆矩阵
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),还需要额外处理:
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 创建可拖拽、可旋转的矩形
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 创建行星轨道动画
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 创建时钟
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 → 绘制(相对中心) |
| 逆变换 | 使用逆矩阵进行坐标转换 |
常用模式
// 围绕中心旋转
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 练习题
基础练习
绘制一个围绕自身中心旋转的正方形动画
实现一个可以水平/垂直翻转的图像查看器
创建一个缩放动画,让图形从小变大再变小
进阶练习
实现一个简单的 2D 场景图:
- 支持嵌套的父子关系
- 子元素继承父元素的变换
创建一个可以拖拽、旋转、缩放的图形编辑器
挑战练习
- 实现一个完整的时钟,包括:
- 时、分、秒针
- 刻度和数字
- 平滑的秒针移动
下一章预告:在第6章中,我们将学习图像与像素操作——如何加载、绘制和处理图像,以及像素级的图像操作。
文档版本:v1.0
字数统计:约 16,000 字
代码示例:45+ 个
