仿射变换与坐标变换:从数学推导到 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 的底层原理,遇到复杂动画就束手无策
  • 你无法理解游戏引擎中节点的 positionrotationscale 是如何协同工作的
  • 你无法在 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