ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Canvas中心点变换:旋转缩放不再跑偏的实用指南

Canvas中心点变换:旋转缩放不再跑偏的实用指南 1. 一个歪得离谱的演示旋转矩形为何绕到了画布角落先说个场景大家感受一下你给一个 Canvas 里的矩形加旋转代码写得特别标准save、rotate、fillRect、restore看着没有任何问题。刷新页面矩形确实转了但它的运动轨迹完全不对——不是绕着自己中心转而是像被一根针钉在画布左上角整个人甩到画面外面去。你以为是自己角度算错了反复调了半小时最后发现转的角度根本没错错的是旋转轴心。这个现象我太熟了群里隔三差五就有人贴代码问。我把最容易踩的那个版本先贴出来const canvas document.getElementById(c); const ctx canvas.getContext(2d); const x 200; const y 150; const w 120; const h 80; ctx.save(); ctx.rotate(Math.PI / 6); // 旋转 30 度 ctx.fillStyle #3498db; ctx.fillRect(x, y, w, h); ctx.restore();这段代码的问题一眼看不出来因为它没有任何语法错误但效果就是歪。你预期的是矩形以自身中心为轴转 30 度实际却是整个坐标系被转了 30 度矩形是在旋转后的坐标系里躺着画出来的。Canvas 的变换改的是坐标系原点不是图形中心这句话才是所有问题的核心。为什么说是歪而不是错因为从数学角度看它确实忠实地执行了你说的话先在全局坐标系里画一个矩形然后把整个坐标系转一下。矩形的四个顶点坐标没变变的是画布上的参考网格。初学者最容易在这个地方绕晕——明明代码是对的为什么画面不对因为对 Canvas 来说图形不是独立对象它只是一堆像素。你能操作的只有坐标系图形永远是绘制在当前坐标系上的产物。很多人在这一步就开始查 API、查参数其实方向错了。正确方向是先搞清楚 Canvas 变换作用的坐标系到底怎么移动然后再看你要画的那个图形处于什么位置。下面我把根因拆开讲讲完你就知道为什么先平移、再变换、再平移是万能解了。2. 中心点偏移的底层原因变换作用对象是坐标系不是图形本身2.1 默认原点在左上角变换中心跟着原点走Canvas 2D 的默认坐标系原点(0, 0)在画布左上角x 轴向右y 轴向下。这是浏览器规定的你改不了。所有的变换方法——rotate()、scale()、translate()——都在这个坐标系里操作。关键在于ctx.rotate(angle)不是把某个图形转一下而是把整个坐标系绕原点转 angle 弧度。坐标系一歪之后画的所有东西都会跟着歪。同理ctx.scale(sx, sy)也不是把图形拉大而是把坐标轴的单位长度拉伸。坐标系原点保持不变默认就是(0, 0)在左上角。这是中心点偏移问题的总根源。你脑子里想的是让那个矩形绕自己的中心转但 Canvas 听到的是让坐标轴绕左上角转。两者根本不是一回事。2.2 用变换矩阵看一切会清晰得多Canvas 内部有一套 2D 变换矩阵标准写法是[ a c e ] [ b d f ]其中a、b、c、d控制旋转和缩放e、f控制平移。浏览器里每个画布上下文都维护着这样一套矩阵值你调用ctx.rotate()、ctx.scale()、ctx.translate()本质都是在改这个矩阵。举个例子旋转 30 度的矩阵长这样[ cos30° -sin30° 0 ] [ sin30° cos30° 0 ]不管你的矩形原来在什么位置套上这个矩阵后它的每个顶点坐标都会被算成相对原点的旋转结果。也就是说原点是那个唯一的旋转轴心。原点在哪旋转中心就在哪。默认情况下原点在左上角所以矩形右侧一圈结果全绕到左上角去。缩放也一样scale(2, 2)相当于给矩阵乘上[ 2 0 0 ] [ 0 2 0 ]图形的每个坐标点都会离原点更远。原点在左上角图形离左上角越远缩放后位移得就越夸张。如果你画一个中心点在(400, 300)的矩形放大 2 倍它会直接跑到(800, 600)附近而不是在原位置变大。这就是为什么缩放也会中心点偏移。2.3 先变换后绘制顺序决定一切还有一个容易忽略的点任何变换只影响它之后绘制的图形。ctx.rotate(Math.PI / 4); // 先变换 ctx.fillRect(0, 0, 100, 100); // 后绘制 → 该矩形被旋转 ctx.fillRect(300, 300, 100, 100); // 这个也会被旋转很多人以为rotate()像 CSS 的transform一样可以指定某个元素其实不是。它是后续所有绘制操作共用的坐标系状态会持续生效直到你用save()/restore()或手动重置。你可以在代码中间调一次ctx.rotate()然后后面画的所有东西全歪这就是最常见的事故现场。所以理解 Canvas 变换本质上是在改一张坐标纸而不是在搬动图形你就成功了一大半。接下来要回答的问题只有一个怎么把坐标纸的旋转中心挪到图形的中心上3. 先平移、再变换、再平移一行改写的中心点变换通用模板3.1 三步走模板解决方案其实很朴素既然变换中心只能跟着原点走那我把原点挪到图形中心不就行了Canvas 提供了translate(dx, dy)可以移动坐标系原点。于是就有了经典三步用translate(cx, cy)把原点移动到目标中心点执行rotate、scale等变换用translate(-cx, -cy)把原点移回原来的位置。一次搞定代码是这样function drawCenteredRect(ctx, cx, cy, w, h, angle) { ctx.save(); // 1. 原点移到矩形中心 ctx.translate(cx, cy); // 2. 绕当前原点也就是矩形中心旋转 ctx.rotate(angle); // 3. 原点移回去恢复世界坐标 ctx.translate(-cx, -cy); // 4. 按原坐标绘制矩形 ctx.fillRect(cx - w / 2, cy - h / 2, w, h); ctx.restore(); }你可以对比一下最开始的错误写法。区别就是多了前后两次translate旋转中心从左上角变成了(cx, cy)。为什么第一次translate之后第二次就不需要用图形自身坐标来画了因为此时你已经把坐标系原点和图形中心重合了rotate是绕这个重合点转的。第二次translate(-cx, -cy)把原点移回去是为了让后续的fillRect(cx-w/2, cy-h/2, w, h)还能按照原来的世界坐标系统来算位置。这个模板可以扩展到任意变换想缩放在第二步换成ctx.scale(sx, sy)想组合变换第二步按顺序写两三个方法也行。核心结构不变。3.2 另一种让自己少绕弯的写法以原点为中心画图如果你觉得每次都要translate(-cx, -cy)再画很啰嗦还有一个更省心的画法把图形本身定义在原点附近中心就是(0, 0)。function drawCenteredRect(ctx, cx, cy, w, h, angle) { ctx.save(); ctx.translate(cx, cy); // 原点移到目标中心 ctx.rotate(angle); // 绕原点旋转也就是绕矩形中心 ctx.fillRect(-w / 2, -h / 2, w, h); // 直接以原点为中心绘制 ctx.restore(); }这样只用一次translate再画一个中心在(0, 0)、左上角在(-w/2, -h/2)的矩形。这个方法更接近先移动画布再在正确位置作画的思维模型我在封装 UI 组件时用得最多。它省去了一次多余的平移也减少了忘记translate(-cx, -cy)的风险。两种写法不是谁替代谁的关系看场景如果图形坐标是固定的世界坐标比如地图上的标记第一种写法直观如果图形坐标本来就是相对自己中心定义的比如可以复用的精灵、图标第二种写法更干净。3.3 为什么这套写法能稳定复现不会算错很多人把translate(-cx, -cy)理解为把图形移回去这是误解。它做的是把坐标系原点复位。中间插了一个rotate之后坐标系的原点虽然还是(cx, cy)这个物理位置但你再次调用的fillRect是在旋转后的坐标系里算坐标的。为了让绘制代码里的数值和世界坐标一致必须把坐标系原点复位到(0, 0)。你可以自己在纸上画一下一个矩形中心在(10, 10)第一步把原点挪到(10, 10)第二步绕原点旋转 90 度第三步把原点挪回(0, 0)。此时再画一个以(10, 10)为中心的矩形效果就是它绕自己中心转了 90 度。这个结论在任意坐标下都成立所以它能稳定复现不需要你每次去心里算偏移量。4. 实战拆解旋转、缩放、镜像中的中心点控制与顺序陷阱4.1 给图片/精灵加旋转最常见需求就是让图片绕着中心旋转比如人物朝向、车轮转动。假设图片宽imgW、高imgH要放到(px, py)这个中心点ctx.save(); ctx.translate(px, py); ctx.rotate(angle); ctx.drawImage(img, -imgW / 2, -imgH / 2); ctx.restore();注意drawImage的起点是图片左上角为了让它中心落在(px, py)要绘制在(-imgW/2, -imgH/2)。这句几乎是精灵类项目的基操。如果你想让图片中心同时出现在某个世界坐标里只是把这个模板的translate参数换成世界坐标就行。多数人物头朝下、图形满屏乱飞的问题都是因为忘了把绘制起点偏移一半尺寸。4.2 缩放的坑中心缩放 vs 左上角缩放缩放同样需要三步模板ctx.save(); ctx.translate(cx, cy); ctx.scale(sx, sy); ctx.translate(-cx, -cy); // 绘制内容 ctx.restore();我见过不少同学直接用ctx.scale(2, 2)然后发现图形飞了非常典型。因为默认缩放以原点为基准图形离原点越远缩放后跑得越远。比如你画一个中心在(300, 200)的圆直接scale(2)圆心会跑到(600, 400)。用上面的模板后你会发现图形在原位置放大/缩小中心不动。这个效果在做 hover 放大、进度环、雷达图扩展时特别有用。还有一个细节如果sx和sy不相等图形会拉伸变形但中心仍然不变——这正好说明变换中心已经被正确设置在图形中心了。4.3 镜像翻转负缩放的隐藏坑镜像在 Canvas 里其实用负缩放实现非常方便。水平翻转就是scale(-1, 1)垂直翻转就是scale(1, -1)。但负缩放会让绘图方向反转如果你不配合中心点处理常常会出现图形跑到画布外的情况。正确的镜像翻转代码ctx.save(); ctx.translate(cx, cy); // 先让原点对准中心 ctx.scale(-1, 1); // 水平镜像 ctx.drawImage(img, -img.width / 2, -img.height / 2); // 以中心为原点绘制 ctx.restore();我第一次写头像翻转时直接scale(-1, 1)结果图片从画布右侧飞到了左侧位置完全不对。后来加了个translate(cx, cy)才明白镜像翻转要以图形中心为对称轴必须先让原点落到对称轴位置上。这个思路也可以推广到任意对称轴不只是中心。4.4 组合变换的顺序陷阱当旋转和缩放混在一起时顺序非常关键。原因在于矩阵乘法不满足交换律rotate * scale和scale * rotate的结果通常不同。看两个例子// 先旋转再缩放 ctx.translate(cx, cy); ctx.rotate(Math.PI / 4); ctx.scale(2, 1); ctx.translate(-cx, -cy); // 结果是先斜切成椭圆再以椭圆的长轴方向旋转 // 先缩放再旋转 ctx.translate(cx, cy); ctx.scale(2, 1); ctx.rotate(Math.PI / 4); ctx.translate(-cx, -cy); // 结果是先旋转再把旋转后的图形横向拉长当你在做陀螺仪、罗盘这类需要同时处理旋转和缩放的组件时这个顺序差异会导致图形朝向完全错误。我的习惯是先想清楚最终视觉效果是什么想让图形先按自己的轴向缩放再把整体转一个角度就用scale先、rotate后想让图形先转角度再在某个方向拉长就用rotate先、scale后。没有绝对标准但一定要在设计阶段固定下来别每次临场试错。5. 连续动画、嵌套元素和高DPI适配下的中心点保持5.1 动画循环里的状态残留动画循环里最忌讳的是变换状态不断累积。比如每帧都执行ctx.translate(cx, cy)帧数一多原点越偏越远中心点自然跟着漂。很多人做旋转动画时发现转动半径越来越大就是这个原因。正确做法是每帧开头重置变换状态function animate() { ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置坐标矩阵 ctx.clearRect(0, 0, canvas.width, canvas.height); // 再用中心点变换模板绘制 ctx.save(); ctx.translate(cx, cy); ctx.rotate(t); ctx.drawImage(sprite, -w / 2, -h / 2); ctx.restore(); requestAnimationFrame(animate); }setTransform(1, 0, 0, 1, 0, 0)是单位矩阵相当于把坐标系恢复成最原始状态。我习惯在动画帧开头先做这步比save/restore的嵌套更有保障因为嵌套层数太多时很容易漏掉一个restore导致状态泄漏。5.2 嵌套元素与分组中心点做复杂场景时经常需要让一组图形先整体旋转里面某个子图形再单独旋转。比如模拟太阳系、机械臂、仪表盘指针。这种场景下中心点偏移问题会从单层变成多层。如果你不能明确每一步的坐标系很容易出现子图形跑飞。我的处理方式是把父级变换和子级变换分层思考。// 父级变换整个仪表盘绕屏幕中心旋转 ctx.save(); ctx.translate(screenCenterX, screenCenterY); ctx.rotate(parentAngle); // 子级变换指针绕自身转轴旋转 ctx.save(); ctx.translate(pointerLocalX, pointerLocalY); ctx.rotate(pointerAngle); drawPointer(ctx); // 绘制指针以 (0,0) 为指针中心 ctx.restore(); ctx.restore();这里有个很容易试错的地方pointerLocalX、pointerLocalY必须是父级坐标系里的相对坐标不能是屏幕全局坐标。比如父级已经translate到(400, 300)指针相对轴心偏移(0, 0)你直接写translate(0, 0)就行。如果你误写成全局坐标(400, 300)指针会离轴心老远。所以做嵌套元素时我的建议是先画一张坐标系示意图标注每一层的原点在哪再动手写代码。书面上的坐标换算每小时能帮你省下大量断点调试时间。5.3 高DPI屏幕DPR缩放后的中心点计算移动端和 Retina 屏的 DPRdevicePixelRatio会让 Canvas 画布物理像素比 CSS 像素多。常见的适配写法是const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);当ctx被setTransform(dpr, 0, 0, dpr, 0, 0)缩放之后你画图使用的坐标系是 CSS 逻辑像素。此时算中心点直接用逻辑坐标const cx cssWidth / 2; const cy cssHeight / 2;千万别再乘一遍 DPR。我第一次适配高 DPI 时就犯了这个错中心点算了两倍图形直接跑到了右下角。原因很简单坐标系已经被 dpr 缩放过了你再用物理像素尺寸去算中心点所有值都会放大 dpr 倍。但如果你没有执行ctx.setTransform(dpr, ...)这步而是直接以物理像素画图那中心点就应该用物理像素尺寸算。一句话概括中心点计算公式必须和你使用的坐标体系保持一致不能混着来。5.4 离屏Canvas与缓存图形性能优化时我经常先把复杂图案画到离屏 Canvas再在每一帧用drawImage把缓存图贴回主画布。离屏 Canvas 里同样会遇到中心点问题。比如你要把一段波形图缓存在离屏画布上然后让波形以自身中心旋转再显示。如果你在离屏 Canvas 里画波形时图形的中心不在(0, 0)那旋转时还是会歪。解决办法有两种生成离屏图的时候就把图案画在以(0, 0)为中心的位置调用时直接translate(cx, cy)rotate(angle)drawImage(offscreen, -w/2, -h/2)离屏图绘制完后用getImageData把中心点信息算进偏移里比较麻烦。我基本只用第一种。离屏画布的坐标原点本来就在左上角绘制时把中心位置定在(0, 0)附近后续主画布应用中心点变换模板能省很多试错。6. 排查中心点偏移的调试清单与我的日常操作技巧6.1 画出可见的中心辅助线遇到中心点偏移问题时我第一件事不是看代码逻辑而是在 Canvas 里画一个辅助十字线ctx.fillStyle red; ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle rgba(255, 0, 0, 0.5); ctx.beginPath(); ctx.moveTo(cx - 20, cy); ctx.lineTo(cx 20, cy); ctx.moveTo(cx, cy - 20); ctx.lineTo(cx, cy 20); ctx.stroke();然后把cx、cy换成我计算出来的中心点值。如果辅助线和图形中心没重合说明计算有偏差如果重合了但旋转还是歪问题多半在变换顺序或 save/restore 上。这个办法比断点调试直观一百倍特别适合处理看着不对但说不出哪里不对的画面。6.2 回顾五个检查点我会按固定顺序排查save/restore 是否成对出现如果外层save了内部又save了一次只restore一次状态就会泄漏导致后续绘图带上前面的变换中心点是否在变换前计算如果你在fillRect之后又改了坐标算出来的中心点已经不再对应图形位置中心点用的是 CSS 像素还是物理像素看代码里有没有做过 DPR 缩放变换顺序是不是与视觉预期一致旋转和缩放顺序反了图形形状都可能变是否在动画循环里重置了矩阵没有重置的话每帧平移会累积中心点会不断漂移。多花两分钟查这份清单通常能锁定八九成问题。剩下的问题基本都出在绘制内容的坐标没处理好比如图形绘制时多加了半个高度之类的这时辅助线又能帮上忙。6.3 封装一个小工具函数告别每次手动算我在实际项目中会封装这样一个函数专门处理以某点为中心执行任意变换function withCenterTransform(ctx, cx, cy, drawFn) { return function transform(...args) { ctx.save(); ctx.translate(cx, cy); drawFn.call(ctx, ...args); ctx.restore(); }; }用的时候先把中心点变换逻辑抽出来函数体里直接以(0, 0)当中心来画图即可const drawCard withCenterTransform(ctx, cx, cy, function() { // 以 (0, 0) 为中心绘制卡片 ctx.fillRect(-80, -50, 160, 100); ctx.fillStyle #fff; ctx.fillRect(-60, -30, 120, 20); });需要旋转时在调用前先ctx.rotate(angle)或者在drawFn里做。这样代码结构非常清晰不会出现十几行里到处是translate、rotate却搞不清状态的情况。6.4 最后一个实用小技巧用减速系数确认中心点如果旋转动画太快肉眼很难看出中心点是否偏移准确可以把角度增量调小比如每帧只转 0.01 弧度。旋转慢下来后辅助十字线和图形中心的偏差会非常清楚。这个方法尤其适合调那种整体旋转子元素独立旋转的复杂组件。我在个人项目里还见过一种情况中心点计算完全正确但图形在旋转过程中会轻微抖动。最后发现是因为每帧的旋转角度用的是常数增量但由于 DPR 适配写得不统一dpr 变化时中心点坐标出现小数舍入误差。解决办法是把坐标值Math.round后再用于绘制或者直接使用整数坐标做布局。这个小坑排查起来很费时间提前知道能省很多事。Canvas 的变换中心点问题说到底就是一句话坐标系原点和图形中心点不对齐。只要掌握了先平移、再变换、再平移的思路后续无论是旋转、缩放、镜像还是多层嵌套都能用同一套逻辑去拆解。如果哪天真在调试中心点偏移时卡住了先停下来画个辅助十字线再对照排查清单走一遍大概率能直接定位到问题。
返回列表