Canvas变换总歪?一招搞定中心点偏移问题(附实战技巧)
Canvas变换总歪?一招搞定中心点偏移问题(附实战技巧)
为啥我画个旋转图形老是飞出屏幕?
兄弟,先别急着砸键盘。我第一次用Canvas画旋转方块的时候,那玩意儿直接转出了屏幕,跟火箭发射似的。当时我还以为电脑中了病毒,结果就是我压根没搞懂Canvas这个"死脑筋"的坐标系。
Canvas这玩意儿,默认把原点(0,0)定在左上角。想象一下,你拿着一张画纸,左上角钉了个钉子,所有旋转、缩放都得绕着这个钉子转。这谁受得了?你画个正方形在屏幕中间,想让它自转,结果它跟疯了一样围着左上角跳舞,转两圈就飞出屏幕边界了。
Canvas坐标系那点破事儿
说白了,Canvas就是个"死心眼"。它才不管你图形在哪儿,反正所有变换都得先经过左上角那个点。就像你在家拖地,拖把的手柄被钉在墙角,你人得围着墙角转,拖把头才能碰到地板的其他位置。
看个最简单的例子:
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
// 画个矩形
ctx.fillStyle = 'red';
ctx.fillRect(100, 100, 50, 50);
// 旋转45度
ctx.rotate(Math.PI / 4);
ctx.fillStyle = 'blue';
ctx.fillRect(100, 100, 50, 50);
运行一下,你会发现蓝色矩形根本没在原地旋转,而是围着左上角那个该死的(0,0)点转了一大圈,位置全乱了。这就是因为rotate默认绕着(0,0)转,而你的矩形在(100,100)位置。
translate到底干了啥?别被名字骗了!
很多人以为translate是"移动图形",其实它移动的是整个坐标系!这区别可大了去了。
想象一下:你把一张透明胶片放在画布上,胶片上画着坐标轴。translate(100, 100)就是把这张胶片往右下移100像素。然后你在这个新坐标系里画图,看起来图形动了,其实是"世界"动了。
// 原始坐标系
ctx.fillRect(0, 0, 50, 50); // 在左上角画个正方形
// 移动坐标系
ctx.translate(100, 100);
ctx.fillRect(0, 0, 50, 50); // 同样的代码,现在正方形在(100,100)位置
看到了吗?两次都是fillRect(0, 0, 50, 50),但因为中间translate了一下,第二次的正方形就跑到新位置了。这就是坐标系搬家的威力!
让图形绕自己中心转的正确姿势
好了,重点来了!想让一个图形绕着自己的中心旋转,正确步骤是:
- 先把坐标系原点搬到图形中心(translate)
- 然后旋转坐标系(rotate)
- 最后在这个新坐标系里画图
function drawRotatedRect(x, y, width, height, rotation) {
const ctx = document.getElementById('myCanvas').getContext('2d');
// 保存当前状态
ctx.save();
// 1. 搬到图形中心
ctx.translate(x + width/2, y + height/2);
// 2. 旋转坐标系
ctx.rotate(rotation);
// 3. 画图(注意:现在原点已经在图形中心了)
ctx.fillStyle = 'green';
ctx.fillRect(-width/2, -height/2, width, height);
// 恢复状态
ctx.restore();
}
// 用一下试试
drawRotatedRect(150, 150, 100, 60, Math.PI / 6);
注意那个fillRect(-width/2, -height/2, width, height),为啥坐标是负的?因为现在坐标系原点就在图形中心,我们要从中心往四周画,所以左上角相对于中心就是(-width/2, -height/2)。
那些年我们一起踩过的坑
顺序搞反,图形直接消失
我最常犯的错误就是把translate和rotate的顺序搞反:
// 错误示范:先rotate再translate
ctx.rotate(Math.PI / 4);
ctx.translate(100, 100);
ctx.fillRect(-25, -25, 50, 50);
这样写,图形会直接转到一个你找不到的角落。为啥?因为先rotate的话,坐标系已经倾斜了,这时候再translate,移动方向也跟着歪了。就像你先把地图转了45度,再往"右"走,其实你是斜着走的。
多个图形互相带偏
当你画多个图形的时候,如果不注意状态管理,后面的图形会被前面的变换影响:
// 画第一个旋转的矩形
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-25, -25, 50, 50);
// 画第二个矩形,想让它不旋转
ctx.fillRect(200, 200, 50, 50); // 卧槽,这个也旋转了!
第二个矩形也会旋转,因为坐标系还保持着之前的旋转状态。这就像你喝完酒没醒酒,接着喝下一杯,状态叠加,直接断片。
save()和restore():Canvas界的Ctrl+Z
解决上面问题的神器就是save()和restore()。这俩货就是Canvas的状态存档系统:
// 正确姿势:每次变换前后都存档
ctx.save(); // 存档当前状态
ctx.translate(100, 100);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-25, -25, 50, 50);
ctx.restore(); // 读档,恢复到save时的状态
// 现在坐标系回到初始状态了,可以安心画下一个
ctx.fillRect(200, 200, 50, 50); // 这次不会旋转了
save()会保存当前的变换矩阵、裁剪区域、样式属性等所有状态。restore()则一键恢复。就像游戏里的存档读档,妈妈再也不用担心我手残了!
实战:画个会自转的齿轮
来,整个实战项目,画个带齿的齿轮,让它自己转起来:
class Gear {
constructor(x, y, radius, teeth, toothSize) {
this.x = x;
this.y = y;
this.radius = radius;
this.teeth = teeth;
this.toothSize = toothSize;
this.rotation = 0;
}
draw(ctx) {
ctx.save();
// 搬到齿轮中心
ctx.translate(this.x, this.y);
// 应用旋转
ctx.rotate(this.rotation);
// 开始画齿轮
ctx.beginPath();
const angleStep = (Math.PI * 2) / this.teeth;
for (let i = 0; i < this.teeth; i++) {
const angle = i * angleStep;
const innerRadius = this.radius - this.toothSize;
const outerRadius = this.radius + this.toothSize;
// 计算齿的内外点
const innerX = Math.cos(angle - angleStep/4) * innerRadius;
const innerY = Math.sin(angle - angleStep/4) * innerRadius;
const outerX = Math.cos(angle) * outerRadius;
const outerY = Math.sin(angle) * outerRadius;
const innerX2 = Math.cos(angle + angleStep/4) * innerRadius;
const innerY2 = Math.sin(angle + angleStep/4) * innerRadius;
if (i === 0) {
ctx.moveTo(innerX, innerY);
} else {
ctx.lineTo(innerX, innerY);
}
ctx.lineTo(outerX, outerY);
ctx.lineTo(innerX2, innerY2);
}
ctx.closePath();
// 填充齿轮
ctx.fillStyle = '#666';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
// 画中心孔
ctx.beginPath();
ctx.arc(0, 0, this.radius * 0.3, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.stroke();
ctx.restore();
}
update() {
this.rotation += 0.02; // 每帧旋转0.02弧度
}
}
// 使用示例
const canvas = document.getElementById('gearCanvas');
const ctx = canvas.getContext('2d');
const gear = new Gear(200, 200, 80, 12, 8);
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
gear.update();
gear.draw(ctx);
requestAnimationFrame(animate);
}
animate();
这个例子完美展示了translate的威力。我们把坐标系搬到齿轮中心,然后旋转坐标系,画齿轮的时候就简单多了。所有齿的位置都是相对于中心计算的,转起来特别稳。
缩放+旋转+平移三连击,怎么组合才不打架?
当你需要同时进行多种变换时,顺序特别重要。记住这个口诀:“先搬位置,再变大小,最后转圈”。
// 正确顺序:translate -> scale -> rotate
function drawComplexTransform(ctx, x, y, scaleX, scaleY, rotation) {
ctx.save();
// 1. 先搬到目标位置
ctx.translate(x, y);
// 2. 再缩放
ctx.scale(scaleX, scaleY);
// 3. 最后旋转
ctx.rotate(rotation);
// 现在画图(注意:所有坐标都是相对于变换后的坐标系)
ctx.fillStyle = 'purple';
ctx.fillRect(-50, -30, 100, 60); // 中心在原点
// 画个箭头指示方向
ctx.beginPath();
ctx.moveTo(50, 0);
ctx.lineTo(30, -10);
ctx.lineTo(30, 10);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// 试试效果
drawComplexTransform(ctx, 300, 200, 1.5, 0.8, Math.PI / 4);
为啥是这个顺序?因为每个变换都会影响后续的变换:
- 如果先rotate再translate,你的translate方向就跟着旋转了
- 如果先scale再translate,你的translate距离也会被缩放
就像穿衣服,你得先穿内裤再穿裤子,顺序乱了会出大事的!
调试神器:画参考线
调试变换的时候,最烦的就是不知道当前坐标系在哪儿。这时候,画参考线就是最直观的方法:
// 画坐标系参考线
function drawCoordinateSystem(ctx, centerX, centerY, size = 100) {
ctx.save();
// 画十字线
ctx.strokeStyle = 'red';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]); // 虚线
// X轴
ctx.beginPath();
ctx.moveTo(centerX - size, centerY);
ctx.lineTo(centerX + size, centerY);
ctx.stroke();
// Y轴
ctx.beginPath();
ctx.moveTo(centerX, centerY - size);
ctx.lineTo(centerX, centerY + size);
ctx.stroke();
// 画原点标记
ctx.fillStyle = 'red';
ctx.fillRect(centerX - 2, centerY - 2, 4, 4);
// 画刻度
ctx.setLineDash([]);
ctx.strokeStyle = 'blue';
ctx.lineWidth = 1;
for (let i = -size; i <= size; i += 20) {
if (i !== 0) {
// X轴刻度
ctx.beginPath();
ctx.moveTo(centerX + i, centerY - 5);
ctx.lineTo(centerX + i, centerY + 5);
ctx.stroke();
// Y轴刻度
ctx.beginPath();
ctx.moveTo(centerX - 5, centerY + i);
ctx.lineTo(centerX + 5, centerY + i);
ctx.stroke();
}
}
ctx.restore();
}
// 在调试复杂变换时使用
function debugTransform(ctx) {
// 先画原始坐标系
drawCoordinateSystem(ctx, 0, 0, 150);
ctx.save();
// 做一些复杂变换
ctx.translate(200, 150);
ctx.rotate(Math.PI / 6);
ctx.scale(1.5, 0.8);
// 画变换后的坐标系
drawCoordinateSystem(ctx, 0, 0, 100);
// 画个图形看看效果
ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
ctx.fillRect(-50, -30, 100, 60);
ctx.restore();
}
这招在调试复杂动画时特别好使。你能一眼看出坐标系被搬到哪儿了,旋转了多少度,缩放比例是多少。比console.log一堆矩阵数据直观一万倍!
矩阵是啥?别被吓到,你天天在用
说到Canvas变换,就不得不提变换矩阵。别被"矩阵"这俩字吓到,其实就是一堆数字,记录了所有的translate、rotate、scale操作。
Canvas提供了一个setTransform()方法,可以直接设置变换矩阵:
// setTransform(a, b, c, d, e, f) 对应下面的矩阵:
// [ a c e ]
// [ b d f ]
// [ 0 0 1 ]
// 相当于:ctx.translate(100, 50); ctx.scale(2, 2); ctx.rotate(Math.PI/4);
const cos = Math.cos(Math.PI/4);
const sin = Math.sin(Math.PI/4);
ctx.setTransform(
2 * cos, // a
2 * sin, // b
-2 * sin, // c
2 * cos, // d
100, // e
50 // f
);
// 现在画的图形就会应用这个复合变换
ctx.fillRect(-25, -25, 50, 50);
但说实话,90%的情况下,用translate、rotate、scale就够了。矩阵这玩意儿,就像核武器,知道有就行,真需要用的时候再掏出来。
性能优化小贴士
做复杂动画的时候,性能很重要。这里有几个小技巧:
// 1. 缓存常用的sin/cos值
const sinCache = {};
const cosCache = {};
function getSin(angle) {
if (!(angle in sinCache)) {
sinCache[angle] = Math.sin(angle);
}
return sinCache[angle];
}
// 2. 预渲染复杂图形到离屏canvas
const offscreen = document.createElement('canvas');
offscreen.width = 100;
offscreen.height = 100;
const offCtx = offscreen.getContext('2d');
// 在离屏canvas上画复杂图形
drawComplexShape(offCtx);
// 主canvas只需要drawImage,避免重复计算
ctx.drawImage(offscreen, x, y);
// 3. 批量操作,减少save/restore调用
ctx.save();
for (let i = 0; i < 100; i++) {
ctx.save(); // 在循环内只save当前这一帧的状态
// 画图形
ctx.restore();
}
ctx.restore();
最后唠叨两句
Canvas变换说白了就一句话:你不是在移动图形,而是在移动"世界"。
想清楚这点,所有问题都迎刃而解。下次再遇到图形旋转飞出屏幕的情况,别急着骂娘,先想想:是不是坐标系没搬对地方?是不是顺序搞反了?是不是忘了save/restore?
记住这几个口诀:
- 先translate,再rotate
- 复杂操作前后要save/restore
- 调试时画参考线
- 性能优化用离屏canvas
掌握了这些,Canvas变换就是你的玩具,想怎么转就怎么转,想怎么变就怎么变。再也不会被"飞出屏幕"的图形气到摔键盘了!
好了,兄弟,去试试吧!有问题再来群里吼一声,咱们一起吐槽Canvas这个"死脑筋"。

487



被折叠的 条评论
为什么被折叠?



