仿射变换与坐标变换:从数学推导到 WebGL 实践
对应月影课程:第 09 章(如何用仿射变换对几何图形进行坐标变换)
源码参考:https://github.com/akira-cn/graphics/tree/master/webgl
目标:掌握仿射变换的数学原理、齐次坐标的本质、以及 GPU 中的矩阵运算实践。
0. 前置知识铺垫:你需要先知道这些
在深入学习仿射变换之前,让我们先确认几个基础概念。如果你已经熟悉,可以快速浏览本节。
0.1 什么是向量
向量是有大小和方向的量。在 2D 平面上,一个向量可以表示为 P(x, y),其中 x 是水平分量,y 是垂直分量。
类比:向量就像一支箭
- 箭的长度 = 向量的大小(模长)
- 箭指向的方向 = 向量的方向
- 箭放在哪里不重要,只要长度和方向相同,就是同一个向量
0.2 什么是矩阵
矩阵是一个由数字排列成的矩形阵列。在图形学中,矩阵是用来"批量处理"向量运算的数学工具。
类比:矩阵就像一个"加工机器"
- 输入:一个向量(原材料)
- 加工:矩阵乘法(机器运转)
- 输出:一个新的向量(成品)
一个 2×2 矩阵: 一个 2D 向量:
| a b | | x |
| c d | | y |
矩阵乘法的结果:
| a b | | x | | a·x + b·y |
| c d | · | y | = | c·x + d·y |
0.3 为什么需要学习仿射变换
如果不懂这个会怎样?
- 你只能用
ctx.fillRect()画固定位置的矩形,无法做旋转、缩放动画 - 你看不懂 CSS
transform的底层原理,遇到复杂动画就束手无策 - 你无法理解游戏引擎中节点的
position、rotation、scale是如何协同工作的 - 你无法在 Shader 中手动构建变换矩阵,只能依赖框架封装
学了之后能做什么?
- 手写粒子爆炸、雨雪飘落等特效
- 理解 Cocos/Unity 中节点的变换底层
- 优化 CSS 动画性能(用 matrix 替代多个 transform 函数)
- 在 Shader 中实现复杂的 2D/3D 变换效果
一、什么是仿射变换
1.1 一句话定义
仿射变换(Affine Transformation)是 "线性变换 + 平移" 的组合。它是拓扑学和图形学中最重要的基础概念之一——利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。
1.2 两个核心性质
| 性质 | 说明 | 意义 |
|---|---|---|
| 保直线性 | 变换前是直线段的,变换后依然是直线段 | 三角形变换后还是三角形,不会变成曲线 |
| 保比例性 | 对两条线段 a 和 b 应用同样的仿射变换,变换前后长度比例保持不变 | 图形的"形状"不发生扭曲 |
类比:仿射变换就像用相机拍照
- 保直线性:照片中的直线还是直线(不考虑镜头畸变)
- 保比例性:照片中两个人的身高比例和现实中一样
- 但照片可以旋转、缩放、平移,这就是仿射变换
1.3 与 CSS transform 的关系
div.block {
transform: rotate(30deg) translate(100px, 50px) scale(1.5);
}
CSS 的 transform 属性本质上就是对元素应用仿射变换。前端工程师每天都在用,但很少深入理解其数学本质。
1.4 为什么对顶点做变换就够了
由于仿射变换保持直线性,因此:
- 对三角形的 3 个顶点做仿射变换
- 变换后的 3 个顶点连成的三角形
= 原三角形经过仿射变换的结果
推论:对任意多边形,只需对其所有顶点做变换,再重新连接即可。
类比:就像捏橡皮泥
- 你不需要捏三角形的每一条内部线段
- 只需要捏三个角(顶点),中间的线自然跟着变形
- 因为橡皮泥的边是直的,捏完角后边还是直的
二、平移变换:最简单的仿射变换
2.1 向量加法
让向量 P(x₀, y₀) 沿着向量 Q(x₁, y₁) 平移:
P' = P + Q
x = x₀ + x₁
y = y₀ + y₁
类比:搬家
- P 是你现在住的位置
- Q 是搬家的距离和方向(向东 3 公里,向北 2 公里)
- P' 就是你新家所在的位置
2.2 问题:平移不是线性变换
线性变换的定义:
T(a·v + b·w) = a·T(v) + b·T(w)
平移不满足:
T(v) = v + t
T(2v) = 2v + t ≠ 2·T(v) = 2v + 2t
结论:平移不是线性变换,不能用 2×2 矩阵表示。
自问自答:
Q: 为什么平移不是线性变换?
A: 因为线性变换要求 T(0) = 0(原点不变),但平移会把原点移到 t 的位置。
Q: 为什么不能用 2×2 矩阵表示平移?
A: 2×2 矩阵乘法只能产生旋转、缩放、剪切等效果,无法产生"加常数"的效果。
因为矩阵乘法的每一项都是 "a·x + b·y" 的形式,没有地方放 tx 和 ty。
2.3 齐次坐标:将平移变为线性变换
什么是齐次坐标(Homogeneous Coordinates)?
齐次坐标是计算机图形学中一种特殊的坐标表示方法。它的核心思想是:通过增加一个维度,把平移这种"加法操作"变成"乘法操作"。
核心思想:升维
2D 点 (x, y) → 3D 齐次坐标 (x, y, 1)
平移矩阵(3×3):
| 1 0 tx |
T = | 0 1 ty |
| 0 0 1 |
验证:
| 1 0 tx | | x | | x + tx |
T · | 0 1 ty | · | y | = | y + ty |
| 0 0 1 | | 1 | | 1 |
完美!平移变成了矩阵乘法。
为什么叫"齐次"(Homogeneous)? 因为 (x, y, 1) 和 (2x, 2y, 2) 表示同一个点。所有标量倍数表示同一个几何点,这种"齐一"的性质称为齐次。
类比:齐次坐标就像分数的约分
- 1/2 和 2/4 是同一个数
- (x, y, 1) 和 (2x, 2y, 2) 是同一个点
- 我们约定 w=1 时是"标准形式",就像分数约分到最简
2.4 初学者常见错误
| 错误 | 原因 | 正确做法 |
|---|---|---|
| 忘记将 (x, y) 转为 (x, y, 1) | 直接用 2D 向量乘 3×3 矩阵 | 始终使用 vec3(position, 1.0) |
| 平移矩阵写错位置 | 把 tx/ty 放到矩阵右下角 | tx/ty 必须在最后一列的前两行 |
| 混淆行主序和列主序 | GLSL 是列主序,但手写矩阵时容易搞混 | GLSL mat3 按列填充 |
三、旋转变换:三角函数推导
3.1 为什么需要旋转
在图形学中,旋转是最常用的变换之一:
- 游戏角色转向
- 图片旋转展示
- 粒子特效的飞散方向
- 3D 模型的朝向
3.2 几何推导(逐步推导,不跳步)
假设向量 P 的长度为 r,与 x 轴夹角为 α
P = (r·cosα, r·sinα) = (x₀, y₀)
将 P 逆时针旋转 θ 角,得到 P':
P' = (r·cos(α+θ), r·sin(α+θ))
使用三角函数和角公式展开:
cos(α+θ) = cosα·cosθ - sinα·sinθ
sin(α+θ) = sinα·cosθ + cosα·sinθ
因此:
x = r·cos(α+θ)
= r·(cosα·cosθ - sinα·sinθ) 【展开和角公式】
= r·cosα·cosθ - r·sinα·sinθ 【分配律展开】
= x₀·cosθ - y₀·sinθ 【替换:r·cosα = x₀, r·sinα = y₀】
y = r·sin(α+θ)
= r·(sinα·cosθ + cosα·sinθ) 【展开和角公式】
= r·sinα·cosθ + r·cosα·sinθ 【分配律展开】
= y₀·cosθ + x₀·sinθ 【替换:r·sinα = y₀, r·cosα = x₀】
= x₀·sinθ + y₀·cosθ 【交换项的顺序】
自问自答:
Q: 为什么旋转公式里有负号?
A: 看 x 的公式:x = x₀·cosθ - y₀·sinθ
负号来自 cos(α+θ) = cosα·cosθ - sinα·sinθ 中的减号。
几何上理解:旋转后,原来的 y 方向对新的 x 方向有"反向"贡献。
Q: 顺时针旋转和逆时针旋转的公式有什么区别?
A: 顺时针旋转相当于 θ 取负值。
cos(-θ) = cosθ(不变),sin(-θ) = -sinθ(变号)
所以顺时针旋转矩阵中 sinθ 的符号会翻转。
3.3 旋转矩阵
| x | | cosθ -sinθ 0 | | x₀ |
| y | = | sinθ cosθ 0 | · | y₀ |
| 1 | | 0 0 1 | | 1 |
简写:
| cosθ -sinθ 0 |
R = | sinθ cosθ 0 |
| 0 0 1 |
类比:旋转矩阵就像一个"方向盘"
- cosθ 控制"保留原方向多少"
- sinθ 控制"转向新方向多少"
- 负号确保转向的正确方向(逆时针)
3.4 顺时针旋转
将 θ 替换为 -θ:
cos(-θ) = cosθ
sin(-θ) = -sinθ
| cosθ sinθ 0 |
R = | -sinθ cosθ 0 |
| 0 0 1 |
3.5 代码实现
class Vector2D {
x: number;
y: number;
rotate(rad: number): Vector2D {
const c = Math.cos(rad); // 计算旋转角的余弦值,复用避免重复计算
const s = Math.sin(rad); // 计算旋转角的正弦值
const [x, y] = [this.x, this.y]; // 保存原始坐标,避免在计算中被覆盖
this.x = x * c + y * -s; // 新 x = 原x·cosθ - 原y·sinθ
this.y = x * s + y * c; // 新 y = 原x·sinθ + 原y·cosθ
return this;
}
}
四、缩放变换
4.1 为什么需要缩放
缩放让我们能够调整图形的大小:
- 游戏角色的成长/缩小效果
- 图片的放大查看
- UI 元素的悬停放大效果
- 粒子系统的生命周期大小变化
4.2 非均匀缩放
让向量 P(x₀, y₀) 沿 x 轴缩放 sx 倍,沿 y 轴缩放 sy 倍:
x = sx · x₀
y = sy · y₀
矩阵形式:
| sx 0 0 |
S = | 0 sy 0 |
| 0 0 1 |
类比:缩放就像用不同倍数的放大镜
- sx = 2, sy = 1:水平方向放大 2 倍,像被横向拉宽的照片
- sx = 1, sy = 0.5:垂直方向压缩一半,像被压扁的图像
- sx = sy = 2:等比例放大,像用 2 倍放大镜看东西
4.3 均匀缩放
sx = sy = s
| s 0 0 |
S = | 0 s 0 |
| 0 0 1 |
五、齐次坐标的本质:为什么需要升维
5.1 核心问题
2D 线性变换(旋转、缩放)可以用 2×2 矩阵表示:
| x | | a b | | x₀ |
| y | = | c d | · | y₀ |
但平移无法表示:
| x | | a b | | x₀ | | tx |
| y | = | c d | · | y₀ | + | ty |
加法破坏了"纯矩阵乘法"的形式。
自问自答:
Q: 为什么不能把平移也放进 2×2 矩阵?
A: 2×2 矩阵乘法的结果只能是:
x' = a·x + b·y
y' = c·x + d·y
这里没有地方放 +tx 和 +ty。
就像用两个旋钮(a,b,c,d)无法同时控制四个自由度。
5.2 升维解决方案
将 2D 点 (x, y) 升维到 3D (x, y, w),其中 w = 1
现在所有变换都是 3×3 矩阵乘法:
旋转: 平移:
| cosθ -sinθ 0 | | 1 0 tx |
| sinθ cosθ 0 | | 0 1 ty |
| 0 0 1 | | 0 0 1 |
缩放:
| sx 0 0 |
| 0 sy 0 |
| 0 0 1 |
统一了!所有仿射变换都是矩阵乘法。
类比:升维就像从 2D 地图升级到 3D 地球仪
- 2D 地图上,你无法直接表示"飞行高度"
- 3D 地球仪上,高度只是一个额外的坐标
- 同理,2D 变换中无法表示平移,但 3D 齐次坐标中,平移只是矩阵的一列
5.3 齐次坐标的 w 分量
一般情况下 w = 1。
当 w ≠ 1 时,(x, y, w) 表示的 2D 点是:
(x/w, y/w)
这有什么用?
- 透视投影时 w 会变化
- 3D 图形管线中,透视除法就是除以 w
- 但在 2D 仿射变换中,w 始终为 1
自问自答:
Q: 为什么透视投影需要 w 变化?
A: 想象一下:远处的物体看起来更小。
在齐次坐标中,(x, y, z, w) 投影到屏幕后是 (x/w, y/w, z/w)。
当 w 随深度增加时,x/w 和 y/w 变小,物体就"缩小"了。
这就是透视效果的数学本质!
六、组合变换:矩阵相乘的顺序
6.1 变换的组合
对向量 P 先进行变换 A,再进行变换 B:
P' = B · (A · P) = (B · A) · P
注意:矩阵乘法从右向左读!
B · A 表示"先 A 后 B"
类比:穿衣服的顺序
- 先穿内衣(A),再穿外套(B)
- 数学表示:外套 · 内衣 · 身体 = (外套 · 内衣) · 身体
- 注意:你不能先穿外套再穿内衣!顺序很重要。
6.2 顺序的重要性
例:先旋转 90°,再平移 (100, 0)
旋转矩阵 R: 平移矩阵 T:
| 0 -1 0 | | 1 0 100 |
| 1 0 0 | | 0 1 0 |
| 0 0 1 | | 0 0 1 |
先旋转后平移:T · R
| 1 0 100 | | 0 -1 0 | | 0 -1 100 |
| 0 1 0 | · | 1 0 0 | = | 1 0 0 |
| 0 0 1 | | 0 0 1 | | 0 0 1 |
先平移后旋转:R · T
| 0 -1 0 | | 1 0 100 | | 0 -1 0 |
| 1 0 0 | · | 0 1 0 | = | 1 0 100|
| 0 0 1 | | 0 0 1 | | 0 0 1 |
结果不同!顺序很重要。
类比:在纸上画箭头
- 先旋转后平移:在原地画箭头,然后移动纸(箭头跟着移动)
- 先平移后旋转:先移动纸,然后在新的位置画箭头并旋转
- 两种操作的结果完全不同!
6.3 标准变换顺序:S → R → T
在图形学中,标准的局部到世界的变换顺序是:
P_world = T · R · S · P_local
即:先缩放,再旋转,最后平移
为什么是这个顺序?
1. 先缩放:在局部坐标系中调整大小
2. 再旋转:在局部坐标系中调整方向
3. 最后平移:移动到世界坐标系的指定位置
如果顺序错了:
- 先平移再缩放:平移距离也会被缩放!
例:先移 100px 再放大 2 倍 = 移了 200px
- 先旋转再缩放:缩放轴会跟着旋转,变成非轴向缩放
例:旋转 45° 后再缩放 x 轴,实际效果是沿 45° 方向拉伸
6.4 初学者常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 矩阵乘法顺序写反 | 变换效果完全不对 | 记住从右向左读:T·R·S = 先S后R最后T |
| 在 GLSL 中矩阵乘向量顺序错误 | 编译可能通过但结果错误 | 始终用 matrix * vector |
| 混淆局部坐标和世界坐标 | 物体围绕错误点旋转 | 先平移到原点,旋转,再平移回去 |
七、WebGL 中的仿射变换实践
7.1 顶点着色器中的矩阵运算
attribute vec2 position; // 顶点属性:2D 顶点坐标 [x, y]
uniform float u_rotation; // 初始旋转角度(弧度)
uniform float u_time; // 当前时间(毫秒),用于驱动动画
uniform float u_duration; // 动画总时长(毫秒)
uniform float u_scale; // 初始缩放比例
uniform vec2 u_dir; // 移动方向(单位向量),控制粒子飞散方向
varying float vP; // 动画进度 [0,1],传给片元着色器控制淡出
void main() {
// p = 动画进度 [0, 1]
// min 函数确保进度不超过 1.0,防止动画结束后继续计算
float p = min(1.0, u_time / u_duration);
// rad = 旋转角度 = 初始角度 + 10π·p(动画中转5周)
// 3.14159265 * 10.0 = 10π ≈ 31.4 弧度 ≈ 5 圈
float rad = u_rotation + 3.14159265 * 10.0 * p;
// scale = 缩放比例,使用缓动函数 p*(2-p)
// 这个函数在 p=0 时为 0,p=1 时为 1,且导数在 p=1 时为 0(平滑结束)
// 图像是一个开口向下的抛物线,从 0 上升到 1
float scale = u_scale * p * (2.0 - p);
// offset = 位移向量,使用 p² 缓动(先慢后快)
// 2.0 * u_dir 控制最大移动距离
// p * p 让移动开始时慢,结束时快
vec2 offset = 2.0 * u_dir * p * p;
// 构建平移矩阵(3×3 齐次矩阵)
// 注意:GLSL mat3 是列主序,所以按列填充
// 第1列:(1, 0, offset.x) 第2列:(0, 1, offset.y) 第3列:(0, 0, 1)
mat3 translateMatrix = mat3(
1.0, 0.0, 0.0, // 第1列的上半部分
0.0, 1.0, 0.0, // 第2列的上半部分
offset.x, offset.y, 1.0 // 第3列(平移分量在这里)
);
// 构建旋转矩阵(3×3 齐次矩阵)
// 第1列:(cos, sin, 0) 第2列:(-sin, cos, 0) 第3列:(0, 0, 1)
mat3 rotateMatrix = mat3(
cos(rad), sin(rad), 0.0, // 第1列
-sin(rad), cos(rad), 0.0, // 第2列
0.0, 0.0, 1.0 // 第3列
);
// 构建缩放矩阵(3×3 齐次矩阵)
// 对角线元素控制缩放,其余为 0
mat3 scaleMatrix = mat3(
scale, 0.0, 0.0, // 第1列
0.0, scale, 0.0, // 第2列
0.0, 0.0, 1.0 // 第3列
);
// 组合变换:平移 × 旋转 × 缩放 × 顶点
// 注意:从右向左读,即先缩放、再旋转、最后平移
// 这是标准的 S → R → T 顺序
vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0);
// gl_Position 需要 vec4,所以把 pos.xy 作为 x,y,z=0.0,w=1.0
gl_Position = vec4(pos.xy, 0.0, 1.0);
vP = p;
}
自问自答:
Q: 为什么缓动函数用 p*(2-p) 而不是直接用 p?
A: p*(2-p) = 2p - p²,它的特点是:
- p=0 时值为 0(开始无缩放)
- p=1 时值为 1(结束为完整缩放)
- 导数在 p=1 时为 0(结束时速度为 0,看起来"刹住"了)
如果用 p,缩放是匀速的,没有这种"缓入缓出"的感觉。
Q: 为什么位移用 p² 而缩放用 p*(2-p)?
A: p² 是"先慢后快",适合模拟加速运动(如爆炸飞散)。
p*(2-p) 是"先快后慢",适合模拟减速运动(如缩放停止)。
不同的缓动函数创造不同的视觉感受。
7.2 片元着色器:根据进度淡出
precision mediump float; // 中等精度浮点数,平衡性能和精度
uniform vec4 u_color; // RGBA 颜色,从 JavaScript 传入
varying float vP; // 从顶点着色器传入的动画进度 [0,1]
void main() {
// RGB 不变,Alpha 随进度从 1 降到 0
// (1.0 - vP) 在 vP=0 时为 1(完全不透明),vP=1 时为 0(完全透明)
// 再乘以 u_color.a 保留原始颜色的透明度设置
gl_FragColor = vec4(u_color.rgb, (1.0 - vP) * u_color.a);
}
7.3 JavaScript:设置 Uniform 并驱动动画
function setUniforms(
gl: WebGLRenderingContext, // WebGL 上下文
program: WebGLProgram, // 着色器程序
triangle: TriangleData // 三角形数据对象
) {
// 获取 uniform 变量的位置(类似指针)
const uColorLoc = gl.getUniformLocation(program, 'u_color');
const uRotationLoc = gl.getUniformLocation(program, 'u_rotation');
const uTimeLoc = gl.getUniformLocation(program, 'u_time');
const uDurationLoc = gl.getUniformLocation(program, 'u_duration');
const uScaleLoc = gl.getUniformLocation(program, 'u_scale');
const uDirLoc = gl.getUniformLocation(program, 'u_dir');
// 将数据传入着色器
gl.uniform4fv(uColorLoc, triangle.color); // 传入颜色(4个浮点数)
gl.uniform1f(uRotationLoc, triangle.rotation); // 传入旋转角度(1个浮点数)
gl.uniform1f(uTimeLoc, triangle.elapsedTime); // 传入已过去的时间
gl.uniform1f(uDurationLoc, triangle.duration); // 传入动画总时长
gl.uniform1f(uScaleLoc, triangle.scale); // 传入初始缩放
gl.uniform2fv(uDirLoc, triangle.direction); // 传入方向向量(2个浮点数)
}
// 动画循环
function update() {
const now = performance.now(); // 获取当前时间戳(高精度)
// 随机生成新三角形(每帧最多 3 个,30% 概率)
for (let i = 0; i < 3; i++) {
if (Math.random() < 0.3) {
triangles.push(createRandomTriangle());
}
}
gl.clear(gl.COLOR_BUFFER_BIT); // 清空画布
// 遍历所有三角形,更新并绘制
for (const tri of triangles) {
tri.elapsedTime = now - tri.startTime; // 计算已播放时间
if (tri.elapsedTime > tri.duration) continue; // 跳过已结束的动画
setUniforms(gl, program, tri); // 设置 uniform 参数
gl.drawArrays(gl.TRIANGLES, 0, 3); // 绘制三角形(3个顶点)
}
// 过滤掉已结束的三角形,避免内存无限增长
triangles = triangles.filter(t => now - t.startTime < t.duration);
requestAnimationFrame(update); // 请求下一帧动画
}
八、CSS transform 的矩阵形式
8.1 从函数到矩阵
/* 原始写法 */
div.block {
transform: rotate(30deg) translate(100px, 50px) scale(1.5);
}
/* 等价于以下矩阵乘法(从右向左):
P' = T · R · S · P
S = | 1.5 0 0 | R = | cos30° -sin30° 0 |
| 0 1.5 0 | | sin30° cos30° 0 |
| 0 0 1 | | 0 0 1 |
T = | 1 0 100 |
| 0 1 50 |
| 0 0 1 |
最终结果(使用 math 库计算):
M = T · R · S = | 1.3 -0.75 61.6 |
| 0.75 1.3 93.3 |
| 0 0 1 |
*/
/* 简写为 CSS matrix(只取前两行前两列 + 最后平移) */
div.block {
transform: matrix(1.3, 0.75, -0.75, 1.3, 61.6, 93.3);
/* matrix(a, b, c, d, tx, ty) 对应:
| a c tx |
| b d ty |
| 0 0 1 |
*/
}
8.2 为什么用 matrix 更优
原始写法字符数:rotate(30deg) translate(100px,50px) scale(1.5) ≈ 54 字符
matrix 写法字符数:matrix(1.3,0.75,-0.75,1.3,61.6,93.3) ≈ 38 字符
压缩率:30%
在大型项目中,大量动画元素使用 matrix 可以显著减少 CSS 文件大小。
类比:matrix 就像快递的"集包"
- 原始写法:三个包裹分别发货(rotate、translate、scale)
- matrix 写法:三个包裹合并成一个(一个 6 参数的矩阵)
- 浏览器解析时,matrix 只需要一次矩阵乘法,性能更好
九、线性变换 vs 仿射变换
| 特性 | 线性变换 | 仿射变换 |
|---|---|---|
| 包含操作 | 旋转、缩放、剪切、镜像 | 线性变换 + 平移 |
| 原点变化 | 原点不变((0,0) 映射到 (0,0)) | 原点可以移动 |
| 矩阵维度 | 2×2 | 3×3(齐次坐标) |
| 公式 | P' = M · P | P' = M · P + T |
| 齐次形式 | 3×3 矩阵最后列为 (0,0,1)ᵀ | 3×3 矩阵最后列为 (tx,ty,1)ᵀ |
| 可叠加性 | 矩阵相乘即可 | 矩阵相乘即可 |
自问自答:
Q: 线性变换和仿射变换的根本区别是什么?
A: 线性变换保持原点不动,仿射变换可以移动原点。
换句话说:仿射变换 = 线性变换 + 平移。
在齐次坐标中,两者都用矩阵乘法表示,区别只在矩阵的最后一列。
Q: 为什么图形学主要用仿射变换而不是线性变换?
A: 因为平移是最常用的操作之一!
如果只用线性变换,所有图形都只能在原点旋转缩放,无法移动位置。
仿射变换通过齐次坐标,把平移也纳入矩阵框架,统一了所有操作。
十、3D 仿射变换
10.1 3D 齐次坐标
3D 点 (x, y, z) → 4D 齐次坐标 (x, y, z, 1)
4×4 变换矩阵:
| m11 m12 m13 tx |
M = | m21 m22 m23 ty |
| m31 m32 m33 tz |
| 0 0 0 1 |
左上 3×3:旋转 + 缩放
右上 1×3:平移
类比:3D 到 4D 的升维和 2D 到 3D 一样
- 2D 用 3×3 矩阵,3D 用 4×4 矩阵
- 原理完全相同,只是多一个维度
- 这就是为什么游戏引擎里 transform 是 4×4 矩阵
10.2 3D 旋转矩阵
绕 X 轴旋转:
| 1 0 0 0 |
Rx =| 0 cosθ -sinθ 0 |
| 0 sinθ cosθ 0 |
| 0 0 0 1 |
绕 Y 轴旋转:
| cosθ 0 sinθ 0 |
Ry =| 0 1 0 0 |
|-sinθ 0 cosθ 0 |
| 0 0 0 1 |
绕 Z 轴旋转:
| cosθ -sinθ 0 0 |
Rz =| sinθ cosθ 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
Cocos Creator 3.8.x 中,3D 节点的
setRotation内部就是构建这些矩阵并相乘。
自问自答:
Q: 为什么绕 Y 轴旋转的矩阵中 sinθ 符号和 X/Z 轴不同?
A: 这是为了维持"右手坐标系"的一致性。
在右手坐标系中,绕 Y 轴旋转时,X 轴向 Z 轴转。
如果符号和其他轴一样,会导致坐标系变成左手系,后续计算会出错。
Q: 3D 中如何表示任意轴旋转?
A: 可以用罗德里格斯旋转公式,或者更常见的:
将任意轴旋转分解为绕 X、Y、Z 三个轴的旋转组合(欧拉角)。
但欧拉角有万向节死锁问题,所以实际工程中常用四元数(Quaternion)。
十一、知识图谱
仿射变换
├── 核心概念
│ ├── 定义:线性变换 + 平移
│ ├── 性质:保直线、保比例
│ └── 与 CSS transform 的关系
│
├── 基本变换
│ ├── 平移:T(tx, ty)
│ ├── 旋转:R(θ) — 三角函数推导
│ └── 缩放:S(sx, sy)
│
├── 数学工具
│ ├── 齐次坐标:(x, y) → (x, y, 1)
│ ├── 齐次矩阵:3×3 统一所有变换
│ └── 矩阵乘法:组合变换,注意顺序
│
├── GPU 实践
│ ├── 顶点着色器中的 mat3
│ ├── 变换顺序:Scale → Rotate → Translate
│ ├── 缓动函数:p*(2-p), p²
│ └── Uniform 传参驱动动画
│
└── 应用扩展
├── CSS matrix 压缩
├── 3D 仿射变换(4×4 矩阵)
└── Cocos 节点 transform 底层
十二、自检清单
| 检查项 | 说明 |
|---|---|
| □ 理解仿射变换的定义 | 线性变换 + 平移 |
| □ 掌握两个核心性质 | 保直线、保比例 |
| □ 能推导旋转矩阵 | 三角函数和角公式 |
| □ 理解齐次坐标 | 为什么需要升维、w=1 的含义 |
| □ 会写平移/旋转/缩放矩阵 | 3×3 齐次矩阵形式 |
| □ 掌握矩阵乘法顺序 | 从右向左读、S→R→T |
| □ 能在顶点着色器中实现 | mat3 矩阵运算 |
| □ 理解 CSS matrix | 6 个参数对应矩阵元素 |
| □ 了解 3D 仿射变换 | 4×4 矩阵、绕轴旋转 |
| □ 能写完整的 WebGL 粒子动画 | 顶点着色器 + Uniform + 动画循环 |
十三、自问自答汇总
Q: 为什么平移不是线性变换? A: 因为线性变换要求 T(0) = 0(原点不变),但平移会把原点移到 t 的位置。
Q: 为什么不能用 2×2 矩阵表示平移? A: 2×2 矩阵乘法的结果只能是 x' = a·x + b·y 的形式,没有地方放 +tx 和 +ty。
Q: 为什么旋转公式里有负号? A: 负号来自 cos(α+θ) = cosα·cosθ - sinα·sinθ 中的减号。几何上,原来的 y 方向对新的 x 方向有"反向"贡献。
Q: 为什么缓动函数用 p(2-p) 而不是直接用 p?* A: p*(2-p) 在结束时导数为 0,产生"缓入缓出"的感觉,比匀速更自然。
Q: 线性变换和仿射变换的根本区别是什么? A: 线性变换保持原点不动,仿射变换可以移动原点。在齐次坐标中,区别只在矩阵的最后一列。
Q: 为什么图形学主要用仿射变换而不是线性变换? A: 因为平移是最常用的操作之一,仿射变换通过齐次坐标把平移也纳入矩阵框架。
Q: 为什么绕 Y 轴旋转的矩阵中 sinθ 符号和 X/Z 轴不同? A: 为了维持右手坐标系的一致性。
十四、初学者常见错误汇总
| 错误 | 原因 | 正确做法 |
|---|---|---|
| 忘记将 (x, y) 转为 (x, y, 1) | 直接用 2D 向量乘 3×3 矩阵 | 始终使用 vec3(position, 1.0) |
| 平移矩阵写错位置 | 把 tx/ty 放到矩阵右下角 | tx/ty 必须在最后一列的前两行 |
| 混淆行主序和列主序 | GLSL 是列主序,但手写矩阵时容易搞混 | GLSL mat3 按列填充 |
| 矩阵乘法顺序写反 | 变换效果完全不对 | 记住从右向左读:T·R·S = 先S后R最后T |
| 在 GLSL 中矩阵乘向量顺序错误 | 编译可能通过但结果错误 | 始终用 matrix * vector |
| 混淆局部坐标和世界坐标 | 物体围绕错误点旋转 | 先平移到原点,旋转,再平移回去 |
| 角度和弧度混用 | GLSL 三角函数接收弧度,但 CSS 用角度 | 始终用弧度:rad = deg * π / 180 |
| 忘记归一化方向向量 | 平移距离被意外缩放 | 传入 u_dir 前先 normalize |