资讯详情

二维变换矩阵详解:从齐次坐标到Canvas实战

📅 2026/9/10 18:21:26 | 华诺云谱 👁 阅读
二维变换矩阵详解:从齐次坐标到Canvas实战
二维变换在计算机图形学里属于那种“看起来简单、用起来全是坑”的知识点。很多初学者第一次接触时觉得不就是平移、旋转、缩放嘛高中数学都学过。但真到写代码时会发现旋转方向不对、缩放中心跑到原点去了、复合变换的结果完全不是预期甚至矩阵乘法的顺序都能把人绕晕。这篇文章我就从图形学课程里“二维变换”这一节出发把矩阵推导、齐次坐标、复合变换、坐标系切换这些内容完整过一遍并结合实际代码演示怎么在Canvas里实现一套可复用的变换工具。适合正在学图形学基础、准备考试或者第一次写渲染相关代码的朋友。1. 换个视角看二维变换为什么所有书上都在讲矩阵二维变换的数学基础就是线性代数但课本不会直接告诉你为什么图形学里非要用矩阵。这里我先给出结论因为矩阵能同时表达变换和坐标系的切换而且矩阵乘法天然支持组合。这两个特性决定了几十年来图形学底层一直都在用矩阵从OpenGL的固定管线到现代GPU的顶点着色器基本思路没变过。1.1 线性变换与矩阵的自然对应如果我们有一个二维点 (x, y)想对它做旋转、缩放、错切这类操作本质上都是在做线性变换。线性变换有一个重要性质就是它只依赖点的坐标分量并且满足两条规则加法保持T(p q) T(p) T(q)数乘保持T(kp) kT(p)也就是说只要确定了变换T对坐标轴单位向量 (1,0) 和 (0,1) 的作用效果这个变换就被唯一确定了。把这两个结果作为列向量拼成一个2x2矩阵就得到了这个线性变换的矩阵表示。例如绕原点逆时针旋转θ角的变换单位向量i (1,0)旋转后变成 (cosθ, sinθ)单位向量j (0,1)旋转后变成 (-sinθ, cosθ)所以旋转矩阵就是[ cosθ -sinθ ] [ sinθ cosθ ]这一段的推导很关键因为很多初学者会直接把矩阵背下来但遇到问为什么第一列是cosθ而不是sinθ时就卡住了。实际上你只需要想清楚单位向量旋转后落到哪里矩阵的每一列就是答案。后面我讲复合变换时也会反复用到这种看列向量的思维方式。1.2 齐次坐标为什么非得加一个维平移变换本身不是线性变换因为它会把原点移动到新的位置不满足 T(0,0)0 这个线性变换的基本条件。如果只用2x2矩阵平移就只能被表示为向量加法也就是 p Mp t。这样在组合多个变换时就很麻烦因为平移和旋转、缩放的表达式形式不统一没法直接写成纯矩阵乘法。解决思路就是升维。把二维平面放进三维空间让原来的点 (x, y) 表示成 (x, y, 1)原来表示方向的向量则用 (x, y, 0)。这样平移就可以写成一个3x3矩阵[ 1 0 tx ] [ 0 1 ty ] [ 0 0 1 ]用这个矩阵去乘 (x, y, 1)得到 (xtx, yty, 1)正好完成平移。同理旋转和缩放也能写成3x3齐次矩阵把它们的右下角设为1、其余位置补0即可。有了齐次坐标后所有二维变换都能统一成 p M·p 这种矩阵乘法的形式复合变换 —— 比如“先旋转再平移” —— 就变成了简单的矩阵乘法比如 p T·R·p。这是一个非常优雅的统一也是所有正经图形学教材费很大篇幅讲齐次坐标的原因。1.3 矩阵乘法顺序行向量还是列向量写代码的时候最容易踩的坑就是矩阵乘法的方向。数学教材通常默认列向量也就是先进行的变换写在左边后进行的变换写在右边比如先旋转R再平移T最终矩阵是 T·R点在右边p T·R·p。但有些图形库和数学库习惯用行向量比如DirectX早期的固定管线和某些着色器代码这时候点写在矩阵左边p p·R·T矩阵乘法的顺序看起来刚好反过来实际效果却是完全一样的。如果你在写自己的数学库建议一开始就统一采用列向量习惯也就是矩阵左乘点。因为主流图形学资料、OpenGL、GLM、Three.js的数学模块基本都是这个约定。确认一个库使用的约定最简单的方法是查看文档里矩阵构造函数的参数顺序或者在源码里找 translate、rotate 这类方法的内部实现。我在调试时也习惯写一个极小的测试对 (0,0) 点应用“先平移(1,0)再旋转90°”如果结果是 (0,1) 还是 (-1,0)就能立刻判断出库的约定。2. 四类基础变换的矩阵推导与直观理解基础变换就四种平移、旋转、缩放、错切。虽然有些教程把反射单列但反射本质上可以看成缩放的一种极端情况某轴缩放因子为-1。下面我把每个变换的矩阵、几何意义、以及推导过程都过一遍。2.1 平移平移矩阵已经在上面给出了。它的作用就是把坐标系原点移动到 (tx, ty)本质上是对齐次坐标第三维的修改。在代码里平移通常非常高频比如你想把物体挪到画布中央或者实现键盘控制移动。平移矩阵里唯一的变量是 tx 和 ty。注意平移与旋转、缩放有一个重要区别平移不会改变一个向量的方向。在齐次坐标里方向向量 (x, y, 0) 与平移矩阵相乘后(x, y) 仍然不变第三个分量0也保持不变。这个特性在后面讲“向量与点的区别”时会再次用到。2.2 旋转旋转矩阵推导前面讲过我再补充一个生活化类比。想象平面里放了一个点它到原点的距离是 r与x轴夹角为α于是坐标是 (r cosα, r sinα)。把它绕原点逆时针旋转θ角新的夹角变成 αθ用两角和差公式展开后就能得到经典的旋转公式x x cosθ - y sinθ y x sinθ y cosθ把系数写到矩阵里就是前面给出的2x2矩阵升到齐次坐标后在右下角补1。逆时针在数学坐标y轴向上里是正方向但在很多图形API里屏幕坐标的y轴是向下的所以视觉上正角度旋转变成了顺时针。这个方向问题我会在后面的常见问题部分专门解释。2.3 缩放缩放矩阵是[ sx 0 0 ] [ 0 sy 0 ] [ 0 0 1 ]直接乘上点坐标就能得到 (sx·x, sy·y)。当 sxsy 时是等比缩放图像形状不变不相等时则会把图形拉伸变形。一个特别容易忽略的细节是浮点数的缩放因子可能导致坐标值逐渐远离原始精度。比如反复缩放一个物体每次乘以1.1经过几十次以后坐标的小数误差会被放大表现就是物体轻微漂移或抖动。工业界的做法是保留物体的原始模型坐标每次从原始值重新计算变换后的坐标而不是在上一轮结果上继续变换。缩放还有负值的情况sx-1, sy1 实际上是沿y轴做镜像。这也是反射变换的本质。在制作角色动画时常利用负缩放做左右翻转但要注意这会导致三角形绕序改变影响背面剔除和光照法线方向。2.4 错切错切矩阵稍微冷门一点但在图形学中其实很常见。水平错切的形式是[ 1 shx 0 ] [ 0 1 0 ] [ 0 0 1 ]它表示横坐标根据纵坐标做线性移动x x shx·yy不变。你可以想象一堆纸牌每张纸牌按高度平移不同的距离。错切用在模拟倾斜字体、地形编辑、以及一些特殊的动画过渡效果中。它虽然不如旋转和缩放常用但一旦遇到需要“扭动”形状的场景会非常方便。比如在2D游戏里做一个被风吹动的草丛底层叶片每帧做微小的水平错切效果比直接整体旋转更自然。3. 复合变换的核心先做还是后做结果天差地别单个变换没什么挑战难点在于组合多个变换。而组合的关键就是矩阵乘法的顺序。这一节我会讲清楚为什么要严格注意顺序以及如何推导出复合矩阵。3.1 矩阵乘法没有交换律一个场景感受一下假设你想让一个正方形先绕着原点旋转45°然后平移到 (100, 0)。如果你先平移后旋转即 T·R 与 R·T 是两个完全不同的结果。我们看具体的数学表达先旋转后平移p T·R·p。这个过程中点先围绕原点转然后再整体移动符合“原地旋转后再移动”的预期。先平移后旋转p R·T·p。点先到新位置再围绕原点旋转结果就是整个图形不仅移动了还绕原点甩了一圈。这种差异非常直观。实际建模时绕物体中心旋转后再定位到场景中与先定位再围绕世界原点旋转两者语义完全不同。所以矩阵乘法的顺序是图形学里反复强调的基础。3.2 绕任意点旋转的矩阵推导我们经常遇到的需求不是绕原点旋转而是绕物体中心或某个指定点 (cx, cy) 旋转。标准解法分三步先把坐标系平移到指定点即平移矩阵 T(-cx, -cy)。绕原点旋转即旋转矩阵 R(θ)。把坐标系移回去即平移矩阵 T(cx, cy)。最终矩阵是M T(cx, cy) · R(θ) · T(-cx, -cy)注意顺序先写的是最后一步。实际应用时如果图形库里的矩阵乘法函数是左乘顺序就按这个顺序从右往左理解。很多同学会问“为什么不能直接对矩形中心做旋转”。因为旋转矩阵的定义默认围绕原点要让旋转中心变成任意点就需要先把所有坐标减去中心让中心临时成为原点旋转后再加回去。这也是复合变换中最经典的三段式思路先平移到原点做旋转变换再平移回去。这个思路不仅适用于旋转还适用于缩放、错切甚至任意顺序的复合变换。3.3 一个实际例子绕中心旋转并缩放假设画布上有一张100x100的图片中心在 (200, 150)你想让它同时绕中心旋转30°并按1.5倍缩放。如果直接写一个旋转矩阵和一个缩放矩阵相乘默认都是绕原点。正确的复合矩阵是M T(200, 150) · R(30°) · S(1.5, 1.5) · T(-200, -150)注意这里旋转和缩放都在原点完成最后再平移到中心。缩放与旋转的顺序根据需求决定一般是先缩放后旋转这样物体形状在自己坐标系里缩放再整体旋转。假如先旋转后缩放旋转后的形状会被再次拉伸产生斜向的变形视觉效果可能不符合预期。我在实际代码里遇到的一个高频错误是把 T(-cx,-cy)、R、S、T(cx,cy) 四个矩阵相乘时代码写反了乘法顺序。所以一个建议是封装辅助函数比如rotateAroundPoint(x, y, angle)内部强制规定三段式顺序。这样能避免在每次调用时都去纠结矩阵顺序。4. 实操用Canvas实现一个简单的二维变换库理论讲完了现在动手写代码。我以浏览器里的Canvas 2D为例手写一个Matrix3类内置平移、旋转、缩放方法并实现一个简单的坐标变换。Canvas原生API其实也有setTransform、transform、translate、rotate、scale这些方法但自己实现一遍更容易理解和排查也方便后续和WebGL、CSS transform做对照。4.1 矩阵类的设计我会用一个长度为9的一维数组存3x3矩阵按列主序存储和OpenGL习惯保持一致。列主序的意思就是数组里第0、1、2个元素是第一列第3、4、5个是第二列第6、7、8个是第三列。这个存储方式在与shader或WebGL交互时可以直接用减少转换。class Mat3 { constructor(...args) { if (args.length 9) { this.m new Float32Array(args); } else { this.m new Float32Array([1,0,0, 0,1,0, 0,0,1]); } } multiply(other) { const a this.m; const b other.m; const out new Float32Array(9); for (let col 0; col 3; col) { for (let row 0; row 3; row) { let sum 0; for (let k 0; k 3; k) { sum a[k * 3 row] * b[col * 3 k]; } out[col * 3 row] sum; } } return new Mat3(...out); } static translation(tx, ty) { return new Mat3(1,0,0, 0,1,0, tx,ty,1); } static rotation(angle) { const c Math.cos(angle); const s Math.sin(angle); return new Mat3(c, s, 0, -s, c, 0, 0, 0, 1); } static scaling(sx, sy) { return new Mat3(sx, 0, 0, 0, sy, 0, 0, 0, 1); } applyToPoint(x, y) { const m this.m; const w m[2] * x m[5] * y m[8]; return { x: (m[0] * x m[3] * y m[6]) / w, y: (m[1] * x m[4] * y m[7]) / w }; } }这段代码里有几个细节值得注意数组里第8个元素是右下角传参时我把它写成1表示齐次坐标的w分量。applyToPoint里计算了w虽然是二维变换w通常恒为1但保留除法可以应对某些特殊矩阵也让代码更容易移植到三维。multiply的循环顺序要小心我写成了a[k * 3 row] * b[col * 3 k]这里 a 是左矩阵b 是右矩阵。如果搞反索引结果矩阵就会错调试时非常隐蔽。Float32Array的好处是内存紧凑、计算快比普通数组更贴近GPU数据布局。在做大量点时性能差异会很明显。4.2 用变换矩阵绘制图形光有矩阵还不够还要把它应用到画布上。Canvas 2D提供了ctx.setTransform(a, b, c, d, e, f)其中 a、b、c、d、e、f 对应矩阵的前两行或前两列具体取决于你的存储方式。由于我使用列主序映射关系是a m[0], b m[1], c m[3], d m[4], e m[6], f m[7]下面这个函数把刚才的Mat3直接设置到Canvas上function applyMat3(ctx, mat) { const m mat.m; ctx.setTransform(m[0], m[1], m[3], m[4], m[6], m[7]); }设置好矩阵后后续的fillRect、arc、moveTo等绘图命令就会自动使用这个变换。比如const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); // 先画一个原始矩形 ctx.fillStyle rgba(255,0,0,0.3); ctx.fillRect(50, 50, 100, 80); // 构造复合变换绕矩形中心旋转30度并放大1.5倍 const centerX 100, centerY 90; let mat Mat3.translation(centerX, centerY); mat mat.multiply(Mat3.rotation(Math.PI / 6)); mat mat.multiply(Mat3.scaling(1.5, 1.5)); mat mat.multiply(Mat3.translation(-centerX, -centerY)); applyMat3(ctx, mat); ctx.fillStyle rgba(0,0,255,0.3); ctx.fillRect(50, 50, 100, 80); ctx.setTransform(1, 0, 0, 1, 0, 0);这里先画红色矩形再画经过变换的蓝色矩形。重点看两个矩形的位置关系蓝色矩形应该以 (100, 90) 为中心旋转放大并且不会偏离原来的中心。如果代码出错一个典型症状就是蓝色矩形跑到了画布左上角那通常是平移矩阵或者中心计算出了问题。4.3 物体坐标系与世界坐标系的转换这个例子背后其实隐含了图形学里特别重要的概念物体坐标系与世界坐标系。矩形在定义时是“在原点附近画的”通过矩阵变换把它放到世界坐标系统里的某个位置。也就是说绘制代码不需要改变只需要改变变换矩阵就能让物体出现在不同地方、不同姿态。这在实际开发里价值巨大例如同一份几何数据可以派生出多个实例各自拥有独立矩阵这在骨骼动画、粒子系统、批量渲染中都是基础思想。我建议初学者在画布上做一个交互实验通过滑动条调节旋转角度、缩放系数和平移量实时观察图形变化。这个实验能帮你建立“矩阵改变 → 图像变化”的直觉。4.4 用矩阵栈管理多个物体在复杂场景里往往需要管理多个物体每个物体有自己的局部变换同时物体还有父子关系比如汽车的车身和车轮。这时矩阵栈就登场了。Canvas原生提供了save()和restore()来保存和恢复状态但如果你想用自己的矩阵实现思路是维护一个数组const matrixStack []; function pushMatrix(m) { matrixStack.push(m); } function popMatrix() { return matrixStack.pop(); }画父子关系物体的逻辑是先push父物体的矩阵乘上子物体的局部变换再绘制子物体最后pop。这样子在子物体内部写的坐标是相对父物体的修改父物体的变换即可整体移动子物体。这和三维场景里骨骼动画的层级思路一模一样。5. 点、向量的不同变换规则与坐标系切换二维变换不只是操作点有时还要变换方向向量比如法线向量、速度方向。在2D里方向向量用齐次坐标表示为 (x, y, 0)。因为第三个分量为0所以平移矩阵不会影响它只有旋转、缩放、错切这类线性变换才起作用。这种区分很关键例如在图形学光照计算里面法线通常不能直接使用顶点的变换矩阵否则缩放不均等时法线方向会错误。5.1 法线向量的变换假设一个三角形经过非等比缩放顶点都在新位置但如果你直接把缩放矩阵应用到原始法线上得到的新法线往往不再与缩放后的平面垂直。这时正确的做法是使用原来变换矩阵的逆转置矩阵。在二维里如果变换矩阵是 M那么法线应该用 (M^{-1})^T 来变换。很多教材在三维部分讲这个问题但二维同样存在。用一个简单的例子验证原始法线为 (1, 0)缩放矩阵为 S(2, 1)。直接把 S 作用到法线得到 (2, 0)方向仍然是水平方向。但原始平面的切线是 (0,1) 方向缩放后切线是 (0,1)仍然垂直这里例子恰好碰巧因为缩放是各轴独立且法线方向与缩放轴对齐。换一个斜方向法线问题就会暴露。实际操作中如果只做旋转和平移法线可以直接乘变换矩阵一旦涉及非等比缩放就要用逆转置矩阵。5.2 从世界坐标到屏幕坐标图形学里另一个常见变换是坐标系切换。比如你用鼠标点击屏幕想判断点了哪个物体需要把屏幕坐标转换到世界坐标。在Canvas里如果已经设置了变换矩阵把鼠标事件里的clientX/clientY转成画布坐标后还要乘以当前变换矩阵的逆矩阵才能得到拾取时的逻辑坐标。function invertMat3(mat) { const m mat.m; const a m[0], b m[1], c m[3], d m[4], e m[6], f m[7]; const det a * d - b * c; if (Math.abs(det) 1e-12) return null; const invDet 1 / det; return new Mat3( d * invDet, -b * invDet, 0, -c * invDet, a * invDet, 0, (c * f - d * e) * invDet, (b * e - a * f) * invDet, 1 ); }这个函数的推导来自2x2矩阵的求逆公式加上平移项的修正。如果你试过鼠标拾取但位置总是偏移大概率是忘了乘逆矩阵。这类问题调试方法也很简单画一个固定物体点击它的中心看拾取结果是否等于该物体自身的逻辑坐标。5.3 坐标变换与变换矩阵的同一性最后强调一个概念变换矩阵既可以理解为“对点做变换”也可以理解为“坐标系之间的映射”。这两种说法是等价的。比如旋转矩阵既可以看作把点绕原点旋转了θ角也可以看作把坐标系逆时针旋转了θ角。做变换时我用同一个矩阵左乘点但解释不同。这个双重视角在组合多个坐标系、实现相机系统时非常有用。6. 常见问题与排查技巧实录这一节是我写代码和带学生时整理的易错点每一条都对应过真实报错或者画面异常。6.1 常见问题速查表现象原因解决方式图形旋转方向与预期相反数学坐标y轴向上屏幕坐标y轴向下使用负角度或检查绘图API的坐标系定义图形移动后总是回到原点附近平移矩阵的tx/ty设置错误或者矩阵存储顺序不对用打印矩阵数值定位错误绕中心旋转的图形发生漂移先平移到了(0,0)但旋转后没有平移回去确认三段式矩阵乘积顺序T·R·T_inv复合变换结果与手算不一致矩阵乘法顺序写反统一使用列向量左乘并检查multiply参数顺序非等比缩放后法线方向错误直接用变换矩阵变换法线使用逆转置矩阵动画中坐标值越来越大到最后出现抖动反复累积变换误差用原始局部坐标重新计算变换结果6.2 排查技巧调试矩阵相关代码时不要靠猜。我惯用的做法是写一个“最小验证用例”在原点画一个10x10的小方块用已知意义很强的矩阵去变换它比如“顺时针旋转90°后再向右平移100”。然后从预期结果推导出数值再打印实际结果。如果结果对不上就逐步打印中间矩阵。这种方式比盯着成千上万行代码更高效。另外可以使用Canvas的网格辅助线。在画布上画十字坐标轴和间距为50的网格能非常直观地看到物体经过变换后的位置。我所有关于2D变换的调试都离不开这个网格它就像数学里的坐标纸能把抽象矩阵还原成几何直觉。7. 更深一步把二维变换推广到三维二维变换学扎实了三维其实只是增加一维而且核心思想完全一样。旋转在三维变成绕某个轴旋转平移、缩放、错切也都有对应的4x4齐次矩阵。Unity、Unreal、OpenGL的模型矩阵本质就是这些扩展。所以二维变换不只是二维绘图的工具更是理解三维图形学的敲门砖。我建议学完这篇文章内容后自己动手做一次推广练习把Mat3改成Mat4用同样的方法实现绕任意轴旋转然后画一个简单的三维线框立方体。这个过程会让你对矩阵的理解上一个台阶。最后再分享一个我在实际项目中养成的习惯永远在代码里加上一个“单位矩阵”测试。也就是一开始先画一个未经任何变换的图形确认它在预期位置然后再逐级添加变换每添加一个变换就确认一次结果。这样就算出了问题也知道是哪一步引入的。很多人一上来就把所有变换堆在一起结果画面完全乱了根本不知道哪里错了这种教训我见过太多次了。一步一步来才是做图形学最靠谱的方式。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。