news 2026/11/6 5:30:41

webgl-fundamentals 系列:WebGL 2D 矩阵变换完全指南——用 3x3 矩阵统一平移、旋转与缩放

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
webgl-fundamentals 系列:WebGL 2D 矩阵变换完全指南——用 3x3 矩阵统一平移、旋转与缩放
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

本指南是 webgl-fundamentals 开源教程系列中的核心章节,讲解如何用 3×3 矩阵数学统一处理 2D 几何的平移(translation)、旋转(rotation)与缩放(scale),并把"像素坐标 → 裁剪空间"的转换一并收进矩阵。读完你不仅能看懂项目仓库中m3工具库的每个函数,还能亲手把原本需要 6~7 步的顶点着色器简化成一行矩阵乘法,并掌握变换顺序、层次动画与旋转中心控制的实战技巧。

从三个独立变换说起:为什么顺序如此重要

在前三篇教程中,我们分别实现了几何平移、几何旋转与几何缩放。平移、旋转、缩放各自都是一种"变换"(transformation),而当时每增加一种变换,都要改写顶点着色器;更麻烦的是,三种变换的应用顺序不同,结果完全不同。

例如:先做(2, 1)缩放、再旋转 30 度、最后平移(100, 0),得到的结果是——

而如果先平移(100, 0)、再旋转 30 度、最后做(2, 1)缩放,结果完全不同:

两者天差地别。更糟糕的是,如果确实需要第二种效果,就必须重新写一个按新顺序应用变换的着色器。这正是矩阵数学要解决的痛点:用一种统一的方式表达任意顺序、任意组合的变换,且无需再改 shader。

3×3 矩阵:把变换装进 9 个格子里

对于 2D 场景,矩阵数学使用3×3 矩阵——本质上就是 9 个格子组成的网格:

1.02.03.0
4.05.06.0
7.08.09.0

运算规则是:把位置向量按列与矩阵逐项相乘,再把结果累加。我们的位置本来只有x和y两个值,但要做这个乘法需要三个值,所以给第三个分量取1:

newX = x * 1.0 + y * 4.0 + 1 * 7.0 newY = x * 2.0 + y * 5.0 + 1 * 8.0 extra = x * 3.0 + y * 6.0 + 1 * 9.0

extra这一行我们并不关心,重点在前两行。接下来分别构造平移、旋转、缩放的矩阵,你会看到它们和之前三篇教程中的手动计算式完全一致。

平移矩阵

设平移量为tx、ty,构造如下矩阵:

1.00.00.0
0.01.00.0
txty1.0

代入乘法并化简(乘以 0 的项消失,乘以 1 的项保持不变):

newX = x + tx; newY = y + ty;

这跟我们平移示例里的代码惊人地相似。

旋转矩阵

和旋转篇一样,只需要目标角度的正弦与余弦:

s = Math.sin(angleToRotateInRadians); c = Math.cos(angleToRotateInRadians);

构造矩阵:

| c | -s | 0.0 | | -- | -- | --- | | s | c | 0.0 | | 0.0 | 0.0 | 1.0 |

代入并化简后得到:

newX = x * c + y * s; newY = x * -s + y * c;

这正是旋转示例中的公式。

缩放矩阵

设缩放因子为sx、sy:

| sx | 0.0 | 0.0 | | -- | --- | --- | | 0.0 | sy | 0.0 | | 0.0 | 0.0 | 1.0 |

化简结果:

newX = x * sx; newY = y * sy;

与缩放示例完全一致。到这里你可能会想:"这不就是把以前做过的事换了个写法,有什么意义?"——真正的魔法在下一步:矩阵相乘。

矩阵相乘:一次应用全部变换

事实证明,我们可以把多个矩阵乘在一起,把所有的变换一次性应用。假设我们有函数m3.multiply(a, b),接收两个矩阵、相乘并返回结果:

var m3 = { multiply: function(a, b) { var a00 = a[0 * 3 + 0]; var a01 = a[0 * 3 + 1]; var a02 = a[0 * 3 + 2]; var a10 = a[1 * 3 + 0]; var a11 = a[1 * 3 + 1]; var a12 = a[1 * 3 + 2]; var a20 = a[2 * 3 + 0]; var a21 = a[2 * 3 + 1]; var a22 = a[2 * 3 + 2]; var b00 = b[0 * 3 + 0]; var b01 = b[0 * 3 + 1]; var b02 = b[0 * 3 + 2]; var b10 = b[1 * 3 + 0]; var b11 = b[1 * 3 + 1]; var b12 = b[1 * 3 + 2]; var b20 = b[2 * 3 + 0]; var b21 = b[2 * 3 + 1]; var b22 = b[2 * 3 + 2]; return [ b00 * a00 + b01 * a10 + b02 * a20, b00 * a01 + b01 * a11 + b02 * a21, b00 * a02 + b01 * a12 + b02 * a22, b10 * a00 + b11 * a10 + b12 * a20, b10 * a01 + b11 * a11 + b12 * a21, b10 * a02 + b11 * a12 + b12 * a22, b20 * a00 + b21 * a10 + b22 * a20, b20 * a01 + b21 * a11 + b22 * a21, b20 * a02 + b21 * a12 + b22 * a22, ]; } }

这段实现同样出现在仓库的模块化工具库 webgl/resources/m3.js 中,逻辑与教程内嵌版本一致,只是额外支持传入可选的dst目标数组(默认用Float32Array存储,便于直接交给 GPU)。

为了让代码更清晰,我们再用三个函数分别构造平移、旋转、缩放矩阵:

var m3 = { translation: function(tx, ty) { return [ 1, 0, 0, 0, 1, 0, tx, ty, 1, ]; }, rotation: function(angleInRadians) { var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); return [ c,-s, 0, s, c, 0, 0, 0, 1, ]; }, scaling: function(sx, sy) { return [ sx, 0, 0, 0, sy, 0, 0, 0, 1, ]; }, };

改造着色器:从三个 uniform 变成一个 mat3

来看改造前后的顶点着色器。旧的着色器里塞满了手工变换步骤:

<script id="vertex-shader-2d" type="x-shader/x-vertex"> attribute vec2 a_position; uniform vec2 u_resolution; uniform vec2 u_translation; uniform vec2 u_rotation; uniform vec2 u_scale; void main() { // 位置乘以缩放 vec2 scaledPosition = a_position * u_scale; // 位置旋转 vec2 rotatedPosition = vec2( scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x, scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x); // 加上平移 vec2 position = rotatedPosition + u_translation; ...

新的着色器只需要一个mat3uniform 和一行乘法:

<script id="vertex-shader-2d" type="x-shader/x-vertex"> attribute vec2 a_position; uniform vec2 u_resolution; uniform mat3 u_matrix; void main() { // 位置乘以矩阵 vec2 position = (u_matrix * vec3(a_position, 1)).xy; ...

注意这里把二维位置a_position提升为三维向量vec3(a_position, 1),正是为了匹配 3×3 矩阵乘法所需的第三个分量1。

在 JavaScript 侧的用法也随之改变:

// 绘制场景 function drawScene() { ... // 计算三个矩阵 var translationMatrix = m3.translation(translation[0], translation[1]); var rotationMatrix = m3.rotation(angleInRadians); var scaleMatrix = m3.scaling(scale[0], scale[1]); // 矩阵相乘(默认顺序:先缩放、再旋转、最后平移) var matrix = m3.multiply(translationMatrix, rotationMatrix); matrix = m3.multiply(matrix, scaleMatrix); // 设置矩阵 gl.uniformMatrix3fv(matrixLocation, false, matrix); // 绘制矩形 gl.drawArrays(gl.TRIANGLES, 0, 18); }

在真实仓库的示例 webgl/webgl-2d-geometry-matrix-transform.html 中,drawScene除了计算并上传矩阵,还完成了gl.viewport、gl.clear、属性指针绑定等常规工作;其中的setGeometry函数用6 个三角形共 18 个顶点拼出字母 "F"(左列、上横档、中横档,每个矩形 2 个三角形),这就是gl.drawArrays(gl.TRIANGLES, 0, 18)中18的来源。滑块控件(平移x/y、角度、缩放scaleX/scaleY)与之前示例完全相同,只是它们在被 shader 使用的方式上变得简单得多。

改变变换顺序:只改乘法顺序,不碰 shader

你可能还是觉得"好处不明显"。但关键在于:现在想换顺序,不再需要写新的着色器,只需改矩阵乘法的顺序:

// 矩阵相乘(顺序变为:先平移、再旋转、最后缩放) var matrix = m3.multiply(scaleMatrix, rotationMatrix); matrix = m3.multiply(matrix, translationMatrix);

对应的成品示例是 webgl/webgl-2d-geometry-matrix-transform-trs.html,代码与默认版唯一的差别就是multiply的调用顺序。

层次动画:复用上一级的矩阵(identity)

矩阵变换的一个典型应用场景是层次动画(hierarchical animation):身体的胳膊、太阳系中行星的卫星、树上的枝条……下一级物体总是在上一级变换的基础上再叠加自己的变换。最简单的演示就是画 5 个 "F",每次都在前一个 F 的矩阵基础上继续叠加:

// 绘制场景 function drawScene() { // 清空画布 gl.clear(gl.COLOR_BUFFER_BIT); // 计算矩阵 var translationMatrix = m3.translation(translation[0], translation[1]); var rotationMatrix = m3.rotation(angleInRadians); var scaleMatrix = m3.scaling(scale[0], scale[1]); // 起始矩阵 var matrix = m3.identity(); for (var i = 0; i < 5; ++i) { // 矩阵相乘 matrix = m3.multiply(matrix, translationMatrix); matrix = m3.multiply(matrix, rotationMatrix); matrix = m3.multiply(matrix, scaleMatrix); // 设置矩阵 gl.uniformMatrix3fv(matrixLocation, false, matrix); // 绘制几何体 gl.drawArrays(gl.TRIANGLES, 0, 18); } }

为此引入了m3.identity——单位矩阵,它相当于数字1:乘以单位矩阵等于什么都没发生,正如X * 1 = X,所以matrixX * identity = matrixX。它让我们能安全地启动"累乘"链条:

var m3 = { identity: function() { return [ 1, 0, 0, 0, 1, 0, 0, 0, 1, ]; }, ...

完整示例见 webgl/webgl-2d-geometry-matrix-transform-hierarchical.html,5 个 "F" 依次嵌套,形成类似"父节点带动子节点"的层次关系。

移动旋转中心:像 Photoshop 一样自由控制

到目前为止,"F" 始终绕左上角旋转(因为公式总是绕原点旋转,而 "F" 的左上角恰好位于原点(0, 0))。但既然我们能做矩阵数学、能自由选择变换顺序,就可以移动原点:

// 生成一个把 'F' 的原点移到其中心的矩阵 var moveOriginMatrix = m3.translation(-50, -75); ... // 矩阵相乘 var matrix = m3.multiply(translationMatrix, rotationMatrix); matrix = m3.multiply(matrix, scaleMatrix); matrix = m3.multiply(matrix, moveOriginMatrix);

效果是 "F" 绕自身中心旋转与缩放,示例见 webgl/webgl-2d-geometry-matrix-transform-center-f.html。用这种技术,你可以绕任意一点旋转或缩放——这正是 Photoshop、Flash 中"旋转中心"(rotation point)可拖动的原理。

投影矩阵:把"像素 → 裁剪空间"也装进矩阵

回到系列第一篇WebGL 基础,着色器里有一段把像素坐标转换为裁剪空间的代码:

// 把矩形从像素坐标转换到 0.0 ~ 1.0 vec2 zeroToOne = position / u_resolution; // 从 0->1 转换到 0->2 vec2 zeroToTwo = zeroToOne * 2.0; // 从 0->2 转换到 -1->+1(裁剪空间) vec2 clipSpace = zeroToTwo - 1.0; gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

逐段分析会发现:第一步"像素 → 0~1"是缩放,第二步还是缩放,第三步是平移,最后一步是把 Y 轴乘-1翻转。这一切都可以放进传入 shader 的矩阵里。虽然可以依次构造 4 个矩阵再相乘,但因为数学很简单,直接写一个按分辨率生成"投影矩阵"(projection matrix)的函数:

var m3 = { projection: function(width, height) { // 注意:该矩阵翻转 Y 轴,使 0 位于顶部 return [ 2 / width, 0, 0, 0, -2 / height, 0, -1, 1, 1 ]; }, ...

在模块化实现 webgl/resources/m3.js 中,projection逻辑完全相同:dst[0] = 2 / width、dst[4] = -2 / height、dst[6] = -1、dst[7] = 1,并附带了相同的注释 "flips the Y axis so 0 is at the top"。

于是顶点着色器被简化到极致——整个main只剩一行核心运算:

<script id="vertex-shader-2d" type="x-shader/x-vertex"> attribute vec2 a_position; uniform mat3 u_matrix; void main() { // 位置乘以矩阵 gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1); } </script>

JavaScript 侧只需把投影矩阵作为乘法链的第一项:

// 绘制场景 function drawScene() { ... // 计算矩阵 var projectionMatrix = m3.projection( gl.canvas.clientWidth, gl.canvas.clientHeight); ... // 矩阵相乘 var matrix = m3.multiply(projectionMatrix, translationMatrix); matrix = m3.multiply(matrix, rotationMatrix); matrix = m3.multiply(matrix, scaleMatrix); ... }

同时删掉了设置u_resolution的代码。借助矩阵数学,我们把这一个原本有 6~7 步的复杂着色器,变成了只有 1 步的极简着色器。完整可运行示例见 webgl/webgl-2d-geometry-matrix-transform-with-projection.html。

更简洁的 API:translate / rotate / scale 链式调用

虽然"先分别生成矩阵、再统一相乘"很常见,但"边生成边乘"同样普遍。可以定义这样的便捷函数:

var m3 = { ... translate: function(m, tx, ty) { return m3.multiply(m, m3.translation(tx, ty)); }, rotate: function(m, angleInRadians) { return m3.multiply(m, m3.rotation(angleInRadians)); }, scale: function(m, sx, sy) { return m3.multiply(m, m3.scaling(sx, sy)); }, ... };

对应模块化实现见 webgl/resources/m3.js(translate)及同文件中的rotate、scale等函数。这样就能把前面 7 行矩阵代码压缩成 4 行:

// 计算矩阵 var matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight); matrix = m3.translate(matrix, translation[0], translation[1]); matrix = m3.rotate(matrix, angleInRadians); matrix = m3.scale(matrix, scale[0], scale[1]);

结果示例:webgl/webgl-2d-geometry-matrix-transform-simpler-functions.html。

读懂矩阵乘法顺序:两种思维模型

前面已经见识了顺序的影响:第一个例子是translation * rotation * scale,第二个是scale * rotation * translation,结果截然不同。给定这样一个完整表达式:

projectionMat * translationMat * rotationMat * scaleMat * position

有两种理解方式:

方式一:从右向左读(很多人觉得更自然)。位置先乘缩放矩阵得到缩放后的位置,再乘旋转矩阵得到旋转缩放后的位置,再乘平移矩阵,最后乘投影矩阵得到裁剪空间坐标:

scaledPosition = scaleMat * position rotatedScaledPosition = rotationMat * scaledPosition translatedRotatedScaledPosition = translationMat * rotatedScaledPosition clipspacePosition = projectionMatrix * translatedRotatedScaledPosition

方式二:从左向右读。此时每个矩阵都在改变画布所代表的空间。画布最初表示裁剪空间(每个方向 -1 ~ +1);每应用一个矩阵,画布代表的空间就随之改变(可以用仓库中的交互图 webgl/lessons/resources/matrix-space-change.html 逐阶段观察):

  1. 无矩阵(或单位矩阵):处于裁剪空间,传入的位置必须是裁剪坐标;
  2. matrix = m3.projection(w, h):进入像素空间,X = 0~400、Y = 0~300,左上角为(0,0),可以看到 Y 轴从"向上为正"翻转为"向下为正"的瞬间;
  3. matrix = m3.translate(matrix, tx, ty):原点移动到tx, ty(示例中为150, 100),空间随之平移;
  4. matrix = m3.rotate(matrix, rotationInRadians):空间绕tx, ty旋转(示例为 33 度);
  5. matrix = m3.scale(matrix, sx, sy):这个已旋转、中心在tx, ty的空间再按 x 缩放 2 倍、y 缩放 1.5 倍。

最后在着色器里执行gl_Position = matrix * position;,position的值实际上就处于这最后一个空间中。两种看法没有对错,选你觉得好理解的即可。

附录:canvas.clientWidth 与 canvas.clientHeight 是什么?

之前引用画布尺寸时一直用canvas.width和canvas.height,但调用m3.projection时却用了canvas.clientWidth和canvas.clientHeight,为什么?

投影矩阵关心的是:如何把裁剪空间(每个维度 -1 ~ +1)转换回像素。但浏览器里有两种"像素":

  • 一种是画布自身的像素数。无论用<canvas width="400" height="300"></canvas>声明,还是用canvas.width = 400; canvas.height = 300;创建,画布内容都是 400×300 像素的图像;
  • 另一种是浏览器实际显示画布的尺寸,由 CSS 决定。例如:
<style> canvas { width: 100vw; height: 100vh; } </style> ... <canvas width="400" height="300"></canvas>

画布会按容器大小拉伸显示,很可能不是 400×300。

仓库里有两个对比示例可以直观验证:

  • webgl/webgl-canvas-width-height.html:使用canvas.width/canvas.height。把窗口拉伸到全屏并调整大小,"F" 会因为画布内部像素数与显示尺寸不一致而变形(此处它通过gl.canvas.width参与投影计算,见其drawScene中m3.projection(gl.canvas.width, gl.canvas.height)的调用);
  • webgl/webgl-canvas-clientwidth-clientheight.html:改用canvas.clientWidth/canvas.clientHeight。这两个属性报告的是浏览器实际显示的大小,因此即便画布内部仍是 400×300,只要按显示尺寸定义宽高比,"F" 就始终不变形。

大多数允许调整画布大小的应用,会努力让canvas.width/canvas.height与canvas.clientWidth/canvas.clientHeight保持一致(希望浏览器显示一个像素对应画布一个像素);但如你所见,这并非唯一选择。几乎在所有情况下,用canvas.clientWidth/canvas.clientHeight计算投影矩阵的宽高比在技术上更准确。

仓库中的完整实现:m3.js 模块

教程页面为了方便阅读,把m3对象内嵌在每个示例的<script>里;仓库同时提供了一份独立的模块化实现 webgl/resources/m3.js,封装为 AMD/全局m3,支持setDefaultType切换Float32Array与普通Array。其核心函数与位置对应如下:

函数作用源码位置
multiply(a, b)3×3 矩阵相乘(支持dst目标数组)webgl/resources/m3.js
identity()生成单位矩阵webgl/resources/m3.js
projection(w, h)生成像素 → 裁剪空间投影矩阵(翻转 Y 轴)webgl/resources/m3.js
translation(tx, ty)生成平移矩阵webgl/resources/m3.js
rotation(angle)生成旋转矩阵webgl/resources/m3.js
translate/rotate/scale(m, ...)便捷链式乘法webgl/resources/m3.js 附近

总结与下一步

通过 3×3 矩阵,我们把平移、旋转、缩放以及像素到裁剪空间的投影,全部统一为"构造矩阵 + 相乘"这一套流程;变换顺序只需调整乘法顺序,层次动画只需从单位矩阵开始逐级累乘,旋转中心则通过移动原点自由控制。这套原理在 3D 中完全通用,只是矩阵从 3×3 扩展为 4×4——详见3D 正交投影篇。

如果还想继续深耕 2D,可以接着学习重现 Canvas 2D 的 drawImage 函数与重现 Canvas 2D 的矩阵栈;若想深入理解矩阵数学本身,仓库相关讨论见矩阵 vs 数学。

  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 5:30:22

张宇1000题几何应用分水岭:核心考点与刷题避坑指南

1. 为什么“几何应用”这一章是很多人的分水岭如果你正在用张宇1000题刷高等数学&#xff0c;翻到第五章“一元函数微分学的应用&#xff08;一&#xff09;——几何应用”的时候&#xff0c;大概率会有两种反应&#xff1a;一种是觉得前面极限、导数都扛过来了&#xff0c;这章…

作者头像 李华
网站建设 2026/11/6 5:30:22

STM32五子棋嵌入式系统:资源受限下的完整人机交互闭环实现

简介&#xff1a;本资源是一款基于STM32F4系列&#xff08;适配原子探索者开发板&#xff09;的嵌入式五子棋对战平台&#xff0c;面向嵌入式初学者与单片机课程设计者&#xff0c;解决图形界面游戏开发中触摸交互、双人逻辑调度、LCD驱动整合等典型工程问题。压缩包共274个文件…

作者头像 李华
网站建设 2026/10/21 1:47:20

CentOS 6.8 源码编译部署 Zabbix-agent 5.0 与 MySQL 监控实战

1. 项目背景与整体设计思路1.1 为什么老系统还要装新版监控CentOS 6.8 这个版本&#xff0c;放在今天来看确实算是“古董级”的存在了。内核 2.6.32&#xff0c;GCC 4.4.7&#xff0c;Python 2.6.6&#xff0c;很多新软件早就官宣不再支持。但现实情况是&#xff0c;不少企业的…

作者头像 李华
网站建设 2026/10/24 15:45:00

Simulink新手入门:系统建模与仿真基础教程

1. Simulink入门&#xff1a;从零开始的系统建模之旅第一次打开Simulink时&#xff0c;那个布满空白画布的界面可能会让新手感到无从下手。作为MathWorks公司推出的基于模型设计&#xff08;Model-Based Design&#xff09;的核心工具&#xff0c;Simulink实际上是一个直观的图…

作者头像 李华
网站建设 2026/10/24 2:02:13

5MW永磁直驱风电直流并网仿真:从参数选型到Simulink实现

前阵子在做一个5MW风电永磁直驱发电机组的直流并网仿真&#xff0c;模型搭到1200V直流母线那一步时&#xff0c;踩了不少坑&#xff0c;也把整个控制链路重新梳理了一遍。今天干脆把这块内容完整写出来——从机组选型逻辑、直流母线电压定标、控制策略拆解&#xff0c;到Simuli…

作者头像 李华