CG-01: 深入理解 2D 变换的数学原理

开个坑学习计算机图形学,希望能坚持下去

原文:baichen.vercel.app/blog/2d-tra... , 可以查看交互式文章

css中的transform

css中的transform属性可以实现2D的变换,包括平移、旋转、缩放和倾斜。

css 复制代码
transform: translate(10px, 20px) rotate(30deg) scale(1.5) skew(10deg, 20deg);

这背后其实是通过矩阵变换实现的,css中的transform属性就是将一个2D的点(x, y)通过一个3x3的矩阵变换为另一个2D的点(x', y')。

你可能会问,为什么跟矩阵有关系?

变换和矩阵是怎么关联上的?

我们可以建立一个坐标系,所有的点都有一个坐标 (x, y),这个坐标可以看作一个向量 [x, y]

如果我们想对一个图形进行缩放,我们可以将每个点的坐标 乘以一个缩放因子。用 <math xmlns="http://www.w3.org/1998/Math/MathML"> x ′ x' </math>x′ 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> y ′ y' </math>y′ 表示变换后的坐标,用 <math xmlns="http://www.w3.org/1998/Math/MathML"> s s </math>s 表示缩放因子,则有:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x ′ = x ∗ s y ′ = y ∗ s x' = x * s \\ y' = y * s </math>x′=x∗sy′=y∗s

这个有2步计算,其实这个算法可以写成乘法,即:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ x ′ y ′ ] = s [ x y ] \begin{bmatrix} x' \\ y' \end{bmatrix} = s \begin{bmatrix} x \\ y \end{bmatrix} </math>[x′y′]=s[xy]

假设缩放因子 <math xmlns="http://www.w3.org/1998/Math/MathML"> s = 1.5 s = 1.5 </math>s=1.5,我们可以用一个矩阵来表示:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1.5 0 0 1.5 ] \begin{bmatrix} 1.5 & 0 \\ 0 & 1.5 \end{bmatrix} </math>[1.5001.5]

可以看到,矩形被放大了1.5倍,但位置保持不变(因为原点在中心)。蓝色虚线框是原始矩形,蓝色实线框是变换后的矩形。

那么对于旋转是怎样的呢?比如我们要实现顺时针旋转30度。对于点(x, y), 他旋转后的位置,可以用三角函数来计算:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x ′ = x ∗ c o s ( 30 d e g ) − y ∗ s i n ( 30 d e g ) y ′ = x ∗ s i n ( 30 d e g ) + y ∗ c o s ( 30 d e g ) x' = x * cos(30deg) - y * sin(30deg) \\ y' = x * sin(30deg) + y * cos(30deg) </math>x′=x∗cos(30deg)−y∗sin(30deg)y′=x∗sin(30deg)+y∗cos(30deg)

这个有2步计算,其实这个算法可以写成矩阵乘法,即:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ x ′ y ′ ] = [ c o s ( 30 d e g ) − s i n ( 30 d e g ) s i n ( 30 d e g ) c o s ( 30 d e g ) ] [ x y ] \begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} cos(30deg) & -sin(30deg) \\ sin(30deg) & cos(30deg) \\ \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} </math>[x′y′]=[cos(30deg)sin(30deg)−sin(30deg)cos(30deg)][xy]

这个旋转矩阵的几何意义是:旋转后的向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ′ v' </math>v′ 是原向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v 和垂直向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ⊥ v_\perp </math>v⊥ 的线性组合,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ′ = v cos ⁡ θ + v ⊥ sin ⁡ θ v' = v \cos\theta + v_\perp \sin\theta </math>v′=vcosθ+v⊥sinθ。

通过上面的例子,我们知道,矩阵的作用就是简化计算,将多个计算步骤合并为一个步骤。

齐次矩阵

上面没有讨论平移,很容易想到,平移一个点就是将点(x, y)加上一个平移量(tx, ty),即:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x ′ = x + t x y ′ = y + t y x' = x + tx \\ y' = y + ty </math>x′=x+txy′=y+ty

但是发现没办法用矩阵乘法来表示, 我们不想让平移这么特殊, 所以为了实现通过矩阵乘法得到 x ' = x + txy ' = y + ty,我们给x和y增加一个维度,即:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ x ′ y ′ 1 ] = [ 1 0 t x 0 1 t y 0 0 1 ] [ x y 1 ] = [ x + t x y + t y 1 ] \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} x + tx \\ y + ty \\ 1 \end{bmatrix} </math> x′y′1 = 100010txty1 xy1 = x+txy+ty1

坐标点(x, y)变成了(x, y, 1),最后一个维度只用来保持矩阵的维度一致,实际没意义。 矩阵增加了1行1列,变成了3x3的矩阵。最后一行固定为0, 0, 1。 最后一列固定为tx, ty, 1。 tx 和 ty 是平移量。

统一变换矩阵

通过引入齐次矩阵,我们可以将缩放、旋转、平移的的计算都写成矩阵的乘法,抽象一下,即:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ x ′ y ′ 1 ] = [ a b t x c d t y 0 0 1 ] [ x y 1 ] \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} a & b & tx \\ c & d & ty \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} </math> x′y′1 = ac0bd0txty1 xy1

其中a, b, c, d, 根据不同的变换,会有不同的值。tx和ty是平移量。

平移矩阵

平移矩阵为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 0 t x 0 1 t y 0 0 1 ] \begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \\ 0 & 0 & 1 \end{bmatrix} </math> 100010txty1

缩放矩阵

缩放矩阵为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ s 0 0 0 s 0 0 0 1 ] \begin{bmatrix} s & 0 & 0 \\ 0 & s & 0 \\ 0 & 0 & 1 \end{bmatrix} </math> s000s0001

其中s是缩放因子。

旋转矩阵

旋转矩阵为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ c o s ( θ ) − s i n ( θ ) 0 s i n ( θ ) c o s ( θ ) 0 0 0 1 ] \begin{bmatrix} cos(\theta) & -sin(\theta) & 0 \\ sin(\theta) & cos(\theta) & 0 \\ 0 & 0 & 1 \end{bmatrix} </math> cos(θ)sin(θ)0−sin(θ)cos(θ)0001

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ 是旋转角度。

斜切(skew)

斜切是把图形沿着x轴或y轴倾斜一定的角度。

有点类似于把一个矩形拉伸成平行四边形。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 t a n ( θ ) 0 0 1 0 0 0 1 ] \begin{bmatrix} 1 & tan(\theta) & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} </math> 100tan(θ)10001

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> θ \theta </math>θ 是斜切角度。

矩阵乘法的好处

矩阵乘法有几个性质特别好:

  1. 结合律:(AB)C = A(BC)
  2. 分配律:A(B+C) = AB + AC
  3. 单位矩阵:I * A = A * I = A

所以我们可以将多个变换矩阵相乘,得到一个最终的变换矩阵。

比如我们要实现一个缩放和旋转的变换,我们可以将缩放矩阵和旋转矩阵相乘,得到一个最终的变换矩阵,这样我们只需要计算一次,就可以得到最终的变换结果。

比如要实现图形先缩放1.5倍,再旋转30度,我们可以将缩放矩阵和旋转矩阵相乘,得到一个最终的变换矩阵,这样我们只需要计算一次,就可以得到最终的变换结果。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> M = [ 1.5 0 0 0 1.5 0 0 0 1 ] [ c o s ( 30 d e g ) − s i n ( 30 d e g ) 0 s i n ( 30 d e g ) c o s ( 30 d e g ) 0 0 0 1 ] = [ 1.3 − 0.75 0 0.75 1.3 0 0 0 1 ] M = \begin{bmatrix} 1.5 & 0 & 0 \\ 0 & 1.5 & 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} cos(30deg) & -sin(30deg) & 0 \\ sin(30deg) & cos(30deg) & 0 \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} 1.3 & -0.75 & 0 \\ 0.75 & 1.3 & 0 \\ 0 & 0 & 1 \end{bmatrix} </math>M= 1.50001.50001 cos(30deg)sin(30deg)0−sin(30deg)cos(30deg)0001 = 1.30.750−0.751.30001

而且可以计算矩阵的逆矩阵,这样我们就可以实现反向变换。

计算逆矩阵可以用库来实现,比如matrix.js

我们把上面这个矩阵的逆矩阵计算出来,然后应用到已经变换过的矩形上,就可以还原到原来的矩形。

设组合变换矩阵为 <math xmlns="http://www.w3.org/1998/Math/MathML"> M M </math>M,原始矩形为 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A,则:

  • 经过 <math xmlns="http://www.w3.org/1998/Math/MathML"> M M </math>M 变换后: <math xmlns="http://www.w3.org/1998/Math/MathML"> A ′ = M ⋅ A A' = M \cdot A </math>A′=M⋅A(变换后的矩形)
  • 对 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ′ A' </math>A′ 应用 <math xmlns="http://www.w3.org/1998/Math/MathML"> M − 1 M^{-1} </math>M−1 变换: <math xmlns="http://www.w3.org/1998/Math/MathML"> M − 1 ⋅ A ′ = M − 1 ⋅ M ⋅ A = I ⋅ A = A M^{-1} \cdot A' = M^{-1} \cdot M \cdot A = I \cdot A = A </math>M−1⋅A′=M−1⋅M⋅A=I⋅A=A(还原到原始矩形)

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> M − 1 = [ 1.3 − 0.75 0 0.75 1.3 0 0 0 1 ] − 1 = [ 0.577 0.333 0 − 0.333 0.577 0 0 0 1 ] M^{-1} = \begin{bmatrix} 1.3 & -0.75 & 0 \\ 0.75 & 1.3 & 0 \\ 0 & 0 & 1 \end{bmatrix} ^ {-1} = \begin{bmatrix} 0.577 & 0.333 & 0 \\ -0.333 & 0.577 & 0 \\ 0 & 0 & 1 \end{bmatrix} </math>M−1= 1.30.750−0.751.30001 −1= 0.577−0.33300.3330.5770001

上面的例子展示了:原始矩形 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A(虚线)→ 经过 <math xmlns="http://www.w3.org/1998/Math/MathML"> M M </math>M 变换得到 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ′ = M ⋅ A A' = M \cdot A </math>A′=M⋅A(实线,变换后的)→ 对 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ′ A' </math>A′ 应用 <math xmlns="http://www.w3.org/1998/Math/MathML"> M − 1 M^{-1} </math>M−1 得到 <math xmlns="http://www.w3.org/1998/Math/MathML"> M − 1 ⋅ A ′ = A M^{-1} \cdot A' = A </math>M−1⋅A′=A(实线,应该与原始矩形完全重合)。实际上, <math xmlns="http://www.w3.org/1998/Math/MathML"> M − 1 ⋅ M = I M^{-1} \cdot M = I </math>M−1⋅M=I(单位矩阵),所以最后一个矩形应该和原始矩形完全重合。

实际应用:实现类似 fabric.js 的画布

有了这些矩阵变换的知识,我们可以实现一个类似 fabric.js 的画布。我们使用 canvas + 矩阵变换来实现。

我们将一步步实现这个功能,从基础画布开始,逐步添加选择、拖动、缩放和旋转功能。

渲染画布和对象

首先,我们需要创建一个 Canvas 画布,并在上面显示一些静态的矩形对象。

每个对象都有一个变换矩阵,初始时使用单位矩阵(无变换):

typescript 复制代码
const objects: CanvasObject[] = [
  {
    id: "rect1",
    type: "rect",
    x: 100,
    y: 100,
    width: 120,
    height: 80,
    fill: "#3b82f6",
    stroke: "#1e40af",
    matrix: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], // 单位矩阵
  },
  // ...
];

在 Canvas 上绘制对象时,我们需要使用 setTransformtransform 方法应用变换矩阵:

typescript 复制代码
const drawObject = (ctx: CanvasRenderingContext2D, obj: CanvasObject) => {
  ctx.save();
  
  // 应用变换矩阵
  const m = obj.matrix;
  ctx.setTransform(m[0][0], m[1][0], m[0][1], m[1][1], m[0][2], m[1][2]);
  
  // 绘制矩形
  ctx.fillStyle = obj.fill;
  ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
  
  ctx.strokeStyle = obj.stroke;
  ctx.lineWidth = 2;
  ctx.strokeRect(obj.x, obj.y, obj.width, obj.height);
  
  ctx.restore();
};

setTransform 方法接受6个参数,对应变换矩阵的前两行:(a, b, c, d, e, f),其中 (a, b, c, d) 是旋转和缩放部分,(e, f) 是平移部分。

对象选择功能

在 Canvas 中,我们需要手动进行点击检测。当用户点击画布时,我们需要:

  1. 将屏幕坐标转换为 Canvas 坐标
  2. 遍历所有对象,检查点击位置是否在对象内部(需要考虑变换矩阵)
  3. 如果点击到对象,记录选中的对象id

点击检测

我们需要实现:

  1. 当点击对象的时候,记录选中的对象id。然后渲染对象的时候,给选中的对象添加一个边框。

  2. 点击空白区域,取消选中。

typescript 复制代码
// 检查点是否在矩形内(考虑变换矩阵)
const isPointInObject = (point: { x: number; y: number }, obj: CanvasObject): boolean => {
  // 将点转换到对象的局部坐标系
  const invMatrix = inverseMatrix(obj.matrix);
  const localPoint = transformPoint(point.x, point.y, invMatrix);
  
  // 检查点是否在矩形内
  return (
    localPoint.x >= obj.x &&
    localPoint.x <= obj.x + obj.width &&
    localPoint.y >= obj.y &&
    localPoint.y <= obj.y + obj.height
  );
};

// 处理画布点击
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  
  // 获取 Canvas 坐标
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  
  // 从后往前遍历对象(后绘制的在上层)
  for (let i = objects.length - 1; i >= 0; i--) {
    if (isPointInObject({ x, y }, objects[i])) {
      setSelectedId(objects[i].id);
      return;
    }
  }
  
  // 没有点击到任何对象
  setSelectedId(null);
}, [objects]);

实现拖动平移功能

拖动平移需要:

  1. 在鼠标按下时记录起始位置
  2. 在鼠标移动时计算位移,创建平移矩阵
  3. 将平移矩阵与对象的现有矩阵相乘

坐标转换:屏幕坐标 → Canvas 坐标

重要 :在 Canvas 元素的事件处理程序中,e.clientXe.clientY屏幕坐标(相对于浏览器视口的像素坐标),而不是 Canvas 的坐标。

Canvas 的坐标系统可能因为 CSS 缩放等因素与屏幕坐标不一致。因此,我们需要将屏幕坐标转换为 Canvas 坐标:

typescript 复制代码
// 将屏幕坐标转换为 Canvas 坐标
const screenToCanvas = useCallback((clientX: number, clientY: number) => {
  const canvas = canvasRef.current;
  if (!canvas) return { x: 0, y: 0 };
  
  const rect = canvas.getBoundingClientRect();
  // 计算缩放比例(考虑 CSS 缩放)
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  
  return {
    x: (clientX - rect.left) * scaleX,
    y: (clientY - rect.top) * scaleY,
  };
}, []);

这样可以将屏幕坐标转换为 Canvas 的实际像素坐标。

平移矩阵工具函数

typescript 复制代码
// 在 matrix-utils.ts 中
export function createTranslateMatrix(tx: number, ty: number): Matrix3x3 {
  return [
    [1, 0, tx],
    [0, 1, ty],
    [0, 0, 1],
  ];
}

拖动处理函数

typescript 复制代码
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
  if (!isDragging || !selectedId) return;

  // 将屏幕坐标转换为 Canvas 坐标
  const canvasPoint = screenToCanvas(e.clientX, e.clientY);
  // 计算在 Canvas 坐标系中的位移
  const dx = canvasPoint.x - dragStart.x;
  const dy = canvasPoint.y - dragStart.y;

  // 创建平移矩阵并应用到对象的变换矩阵
  const translateMat = createTranslateMatrix(dx, dy);
  setObjects((prev) =>
    prev.map((obj) => {
      if (obj.id === selectedId) {
        // 将平移矩阵与现有矩阵相乘
        const newMatrix = multiplyMatrix(translateMat, obj.matrix);
        return { ...obj, matrix: newMatrix };
      }
      return obj;
    })
  );

  setDragStart(canvasPoint);
}, [isDragging, selectedId, dragStart, screenToCanvas]);

实现缩放控制点

缩放功能需要:

  1. 计算对象的边界框(考虑变换)
  2. 在边界框的四个角显示控制点
  3. 拖动控制点时,根据到中心点的距离计算缩放比例
  4. 相对于对象中心进行缩放

边界框计算

typescript 复制代码
const getObjectBounds = useCallback((obj: CanvasObject) => {
  const corners = [
    { x: obj.x, y: obj.y },
    { x: obj.x + obj.width, y: obj.y },
    { x: obj.x + obj.width, y: obj.y + obj.height },
    { x: obj.x, y: obj.y + obj.height },
  ];

  // 将角点应用变换矩阵
  const transformedCorners = corners.map((corner) =>
    transformPoint(corner.x, corner.y, obj.matrix)
  );

  const xs = transformedCorners.map((c) => c.x);
  const ys = transformedCorners.map((c) => c.y);
  const minX = Math.min(...xs);
  const maxX = Math.max(...xs);
  const minY = Math.min(...ys);
  const maxY = Math.max(...ys);

  return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
}, []);

缩放处理

缩放需要相对于对象中心进行,所以需要:

  1. 平移到原点
  2. 缩放
  3. 平移回原位置
typescript 复制代码
if (controlPointType === "corner") {
  const centerX = transformOrigin.x;
  const centerY = transformOrigin.y;

  // 计算缩放比例
  const oldDist = Math.sqrt(
    (dragStart.x - centerX) ** 2 + (dragStart.y - centerY) ** 2
  );
  const newDist = Math.sqrt(
    (canvasPoint.x - centerX) ** 2 + (canvasPoint.y - centerY) ** 2
  );
  const scale = newDist / oldDist;

  // 相对于中心点缩放
  const translateToOrigin = createTranslateMatrix(-centerX, -centerY);
  const scaleMat = createScaleMatrix(scale, scale);
  const translateBack = createTranslateMatrix(centerX, centerY);
  const newMatrix = multiplyMatrix(
    translateBack,
    multiplyMatrix(scaleMat, translateToOrigin)
  );
  const finalMatrix = multiplyMatrix(newMatrix, selectedObj.matrix);

  setObjects((prev) =>
    prev.map((obj) =>
      obj.id === selectedId ? { ...obj, matrix: finalMatrix } : obj
    )
  );
}

实现旋转控制点

旋转功能需要:

  1. 在对象顶部添加旋转控制点
  2. 拖动时计算角度差
  3. 相对于对象中心进行旋转

旋转矩阵工具函数

typescript 复制代码
// 在 matrix-utils.ts 中
export function createRotateMatrix(angle: number): Matrix3x3 {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return [
    [cos, -sin, 0],
    [sin, cos, 0],
    [0, 0, 1],
  ];
}

旋转处理

旋转操作的核心是计算角度差。通过 Math.atan2 函数,我们可以从两个点的坐标计算出角度,然后计算它们的差值:

typescript 复制代码
if (controlPointType === "rotate") {
  const centerX = transformOrigin.x;
  const centerY = transformOrigin.y;

  // 计算角度差
  const oldAngle = Math.atan2(dragStart.y - centerY, dragStart.x - centerX);
  const newAngle = Math.atan2(canvasPoint.y - centerY, canvasPoint.x - centerX);
  const deltaAngle = newAngle - oldAngle;

  // 相对于中心点旋转
  const translateToOrigin = createTranslateMatrix(-centerX, -centerY);
  const rotateMat = createRotateMatrix(deltaAngle);
  const translateBack = createTranslateMatrix(centerX, centerY);
  const newMatrix = multiplyMatrix(
    translateBack,
    multiplyMatrix(rotateMat, translateToOrigin)
  );
  const finalMatrix = multiplyMatrix(newMatrix, selectedObj.matrix);

  setObjects((prev) =>
    prev.map((obj) =>
      obj.id === selectedId ? { ...obj, matrix: finalMatrix } : obj
    )
  );
}
相关推荐
米花丶1 小时前
解决前端监控上报 Script Error实践
前端·javascript
JarvanMo1 小时前
如何在 Flutter 应用中大规模实现多语言翻译并妥善处理 RTL(从右到左)布局?
前端
Haha_bj1 小时前
iOS深入理解事件传递及响应
前端·ios·app
1024小神1 小时前
用html和css实现放苹果的liquidGlass效果
前端
im_AMBER1 小时前
Canvas架构手记 07 状态管理 | 组件通信 | 控制反转
前端·笔记·学习·架构·前端框架·react
JarvanMo1 小时前
理解 Flutter 中的 runApp() 与异步初始化
前端
掘金安东尼1 小时前
🧭 前端周刊第442期(24–30 Nov 2025)
前端
h***8561 小时前
Rust在Web中的前端开发
开发语言·前端·rust
深色風信子1 小时前
Vue 富文本编辑器
前端·javascript·vue.js·wangeditor·vue 富文本·wangeditor-text·前端富文本