Skip to content

第3章:坐标系统与矩阵变换

教学目标: 深入理解无限画布的坐标系统与矩阵变换原理,从数学本质到代码实现,从单点变换到复杂场景应用的完整技术链路。

📚 目录

  1. 为什么需要理解坐标系统
  2. 三个坐标系的深度解析
  3. 坐标转换的数学本质
  4. 2D仿射变换矩阵
  5. 矩阵运算的代码实现
  6. 复合变换与实际应用
  7. 矩阵分解原理
  8. 包围盒与碰撞检测
  9. 向量运算与角度计算
  10. 实战案例分析

一、为什么需要理解坐标系统?

1.1 一个直观的问题

想象一个场景:用户用鼠标点击了屏幕上的某个位置,你需要判断他点击了哪个图形元素。

听起来简单?但请考虑:

  • 画布被缩放到了 150%
  • 用户平移了画布,向右移动了 200 像素
  • 被点击的图形本身还旋转了 45 度
  • 图形还嵌套在一个组内,组也有自己的变换

鼠标事件给你的是 event.clientX = 523, event.clientY = 312

问题:这个点对应画布上的哪个坐标?这个坐标落在哪个图形内?

这就是坐标系统要解决的核心问题。

1.2 无限画布的"无限"来自哪里?

传统画布有固定尺寸:

javascript
// 传统Canvas
<canvas width="1920" height="1080" />

画布就这么大,画出去就看不见了。

无限画布的秘密: 画布本身还是有限的(屏幕就那么大),但通过坐标变换,我们可以"观察"无限大的空间。

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│                    无限大的世界空间                          │
│                         ↑                                   │
│        ┌────────┐       │       ┌────────┐                  │
│        │ 元素A  │       │       │ 元素B  │                  │
│        └────────┘       │       └────────┘                  │
│                         │                                   │
│    ←────────────────────┼────────────────────→              │
│                         │                                   │
│        ┌────────┐       ↓       ┌────────┐                  │
│        │ 元素C  │               │ 元素D  │                  │
│        └────────┘               └────────┘                  │
│                                                             │
│    ╔═══════════════╗                                        │
│    ║  视口窗口    ║ ← 用户实际看到的区域                    │
│    ║  (有限大小)  ║                                         │
│    ╚═══════════════╝                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

本质: 无限画布 = 有限视口 + 坐标变换。

1.3 本章要解决的问题

读完本章,你将能够回答:

  1. 用户点击屏幕 (523, 312),对应世界空间的哪个坐标?
  2. 世界空间的元素 (1000, 2000),在屏幕上的哪个位置?
  3. 以鼠标位置为中心缩放,需要怎么计算新位置?
  4. 如何判断一个点是否在旋转后的矩形内?
  5. 如何从变换矩阵中提取出旋转角度和缩放比例?

二、三个坐标系的深度解析

2.1 屏幕坐标系(Screen Coordinates)

定义: 以浏览器视口左上角为原点的坐标系。

┌─────────────────────────────────────────────────────────┐
│ ← 浏览器窗口                                             │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 地址栏、工具栏等                                     │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ (0, 0)                                              │ │
│ │ ↓                                                   │ │
│ │ ┌──────────────────────────────────────────────┐    │ │
│ │ │                                              │    │ │
│ │ │    页面内容                                  │    │ │
│ │ │                                              │    │ │
│ │ │         ● (523, 312)  ← 鼠标事件坐标         │    │ │
│ │ │                                              │    │ │
│ │ └──────────────────────────────────────────────┘    │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

获取方式:

typescript
element.addEventListener('click', (event) => {
    const screenX = event.clientX;  // 相对于视口
    const screenY = event.clientY;
    
    // 注意:不是 event.pageX/pageY(包含滚动偏移)
    // 也不是 event.screenX/screenY(相对于显示器)
});

关键点:

  • clientX/clientY:相对于浏览器视口左上角
  • pageX/pageY:相对于文档左上角(包含页面滚动)
  • screenX/screenY:相对于显示器左上角(极少使用)
  • offsetX/offsetY:相对于事件目标元素(可能不是你想要的)

2.2 视口坐标系(Viewport Coordinates)

定义: 以画布容器左上角为原点的坐标系。

┌─────────────────────────────────────────────────────────┐
│  网页内容                                               │
│  ┌──────────────────────┐  ┌──────────────────────────┐ │
│  │ 工具栏面板           │  │                          │ │
│  │ (不是画布)           │  │  (0, 0)                  │ │
│  │                      │  │    ↓ 视口坐标原点        │ │
│  │                      │  │  ┌─────────────────────┐ │ │
│  │                      │  │  │                     │ │ │
│  │                      │  │  │   画布容器          │ │ │
│  │                      │  │  │                     │ │ │
│  │                      │  │  │  ● (viewX, viewY)   │ │ │
│  │                      │  │  │                     │ │ │
│  │                      │  │  └─────────────────────┘ │ │
│  └──────────────────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

从屏幕坐标转换:

typescript
// 获取画布容器的位置
const containerRect = canvasContainer.getBoundingClientRect();

// 屏幕坐标 → 视口坐标
const viewportX = event.clientX - containerRect.left;
const viewportY = event.clientY - containerRect.top;

为什么需要这一步?

画布容器不一定在页面左上角。可能有侧边栏、工具栏等其他元素。视口坐标让我们专注于画布区域内的相对位置。

代码位置:

typescript
// infinite-plugins/src/plugins/viewport-plugin/hooks/use-gesture.ts

function handleZoom(event: WheelEvent) {
    const { left, top } = editor.containerRect;  // 容器位置
    const x = event.clientX - left;              // 转为视口坐标
    const y = event.clientY - top;
    // ...
}

2.3 世界坐标系(World Coordinates)

定义: 画布内容的"真实"坐标系,与视口变换无关。

这是最关键的坐标系。 所有元素的位置、尺寸都在世界坐标系中定义。

世界坐标系(无限大)
─────────────────────────────────────────────────────────

           │    ┌────────┐
           │    │ 元素A  │ ← left: 100, top: 50
           │    │        │    (世界坐标)
           │    └────────┘

───────────┼────────────────────────────────────────────→

           │         ┌─────────┐
           │         │ 元素B   │ ← left: 500, top: 300
           │         │         │    (世界坐标)
           │         └─────────┘

关键特性:

  1. 不变性: 无论用户如何缩放、平移画布,元素在世界坐标系中的位置不变
  2. 逻辑性: 元素的 left, top, width, height 都是世界坐标
  3. 无限性: 没有边界限制,可以放置在任意位置

2.4 三个坐标系的关系

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   屏幕坐标系                                                │
│   ═══════════                                               │
│   (event.clientX, event.clientY)                           │
│                                                             │
│         │                                                   │
│         │  Step 1: 减去容器偏移                            │
│         │  x = clientX - containerRect.left                │
│         │  y = clientY - containerRect.top                 │
│         ↓                                                   │
│                                                             │
│   视口坐标系                                                │
│   ═══════════                                               │
│   (viewportX, viewportY)                                   │
│                                                             │
│         │                                                   │
│         │  Step 2: 应用视口逆变换                          │
│         │  worldX = (viewportX - viewport.x) / zoom        │
│         │  worldY = (viewportY - viewport.y) / zoom        │
│         ↓                                                   │
│                                                             │
│   世界坐标系                                                │
│   ═══════════                                               │
│   (worldX, worldY)                                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

反向转换:

世界坐标 → 视口坐标:
viewportX = worldX * zoom + viewport.x
viewportY = worldY * zoom + viewport.y

视口坐标 → 屏幕坐标:
screenX = viewportX + containerRect.left
screenY = viewportY + containerRect.top

三、坐标转换的数学本质

3.1 视口状态的三个参数

视口由三个参数定义:

typescript
interface ViewportState {
    x: number;      // 视口原点在世界坐标系中的 X 偏移
    y: number;      // 视口原点在世界坐标系中的 Y 偏移
    zoom: number;   // 缩放比例(1 = 100%,0.5 = 50%,2 = 200%)
}

理解这三个参数:

假设 viewport = { x: 100, y: 50, zoom: 2 }

含义:
- zoom = 2:世界坐标放大 2 倍显示
- x = 100:世界原点在视口中显示在 x=100 的位置
- y = 50:世界原点在视口中显示在 y=50 的位置

视觉效果:
┌───────────────────────────────────────┐
│  视口                                  │
│  (0,0)                                │
│     ─────────────────────────────→    │
│     │                                 │
│     │  ← 100px →                      │
│     │           ↓ 50px                │
│     │           ●(100,50)             │
│     │           ↑                     │
│     │           世界坐标原点在这里显示  │
│     ↓                                 │
└───────────────────────────────────────┘

3.2 正向变换:世界坐标 → 视口坐标

公式推导:

设:世界坐标 (wx, wy),视口状态 (vx, vy, zoom)
求:视口坐标 (sx, sy)

步骤1:缩放
    世界坐标先乘以缩放比例
    wx' = wx * zoom
    wy' = wy * zoom

步骤2:平移
    加上视口偏移
    sx = wx' + vx = wx * zoom + vx
    sy = wy' + vy = wy * zoom + vy

最终公式:
    sx = wx * zoom + vx
    sy = wy * zoom + vy

代码实现:

typescript
// infinite-renderer/src/viewport/viewport.ts

getGlobalPoint(pos: IPointData, newPos?: Point, skipUpdate?: boolean): Point {
    // 内部调用 page.getGlobalPoint
    // 本质是:worldPoint * worldTransform
    return this.page.getGlobalPoint(pos, newPos, skipUpdate);
}

PixiJS 内部的 worldTransform 矩阵已经包含了视口的位置和缩放信息。

3.3 逆向变换:视口坐标 → 世界坐标

公式推导:

设:视口坐标 (sx, sy),视口状态 (vx, vy, zoom)
求:世界坐标 (wx, wy)

从正向公式:
    sx = wx * zoom + vx
    sy = wy * zoom + vy

解出 wx, wy:
    wx = (sx - vx) / zoom
    wy = (sy - vy) / zoom

代码实现:

typescript
// infinite-renderer/src/viewport/viewport.ts

getLocalPoint(pos: IPointData, newPos?: Point, skipUpdate?: boolean): Point {
    // 内部调用 page.getLocalPoint
    // 本质是:screenPoint * inverse(worldTransform)
    return this.page.getLocalPoint(pos, newPos, skipUpdate);
}

3.4 一个完整的例子

typescript
// 场景设置
const viewport = { x: 200, y: 100, zoom: 1.5 };
const containerRect = { left: 50, top: 30 };

// 用户点击了屏幕 (400, 250)
const event = { clientX: 400, clientY: 250 };

// Step 1: 屏幕坐标 → 视口坐标
const viewportX = event.clientX - containerRect.left;  // 400 - 50 = 350
const viewportY = event.clientY - containerRect.top;   // 250 - 30 = 220

// Step 2: 视口坐标 → 世界坐标
const worldX = (viewportX - viewport.x) / viewport.zoom;  // (350 - 200) / 1.5 = 100
const worldY = (viewportY - viewport.y) / viewport.zoom;  // (220 - 100) / 1.5 = 80

// 结论:用户点击了世界坐标 (100, 80)

// 验证:反向计算
// 世界坐标 (100, 80) → 视口坐标
const checkViewX = 100 * 1.5 + 200;  // 150 + 200 = 350 ✓
const checkViewY = 80 * 1.5 + 100;   // 120 + 100 = 220 ✓

四、2D仿射变换矩阵

4.1 为什么需要矩阵?

问题: 单独存储 x, y, zoom 只能表示简单的平移和缩放。如果还有旋转呢?

typescript
// 简单方案(不支持旋转)
interface SimpleTransform {
    x: number;
    y: number;
    zoom: number;
}

// 如何表示旋转?
// 如何表示倾斜?
// 如何组合多个变换?

答案: 使用变换矩阵,可以统一表示所有 2D 仿射变换。

4.2 2D 仿射变换矩阵的结构

┌         ┐
│ a  c tx │
│ b  d ty │
│ 0  0  1 │
└         ┘

6 个有效参数的含义:

参数几何含义单位矩阵值变换公式
aX 轴缩放 + X 方向的水平剪切1newX 中 x 的系数
bY 轴缩放 + Y 方向的垂直剪切0newY 中 x 的系数
cX 轴倾斜0newX 中 y 的系数
dY 轴缩放1newY 中 y 的系数
txX 轴平移0newX 的常数项
tyY 轴平移0newY 的常数项

4.3 点的变换计算

将点 (x, y) 通过矩阵变换为 (x', y')

┌   ┐   ┌         ┐   ┌   ┐
│ x'│   │ a  c tx │   │ x │
│ y'│ = │ b  d ty │ × │ y │
│ 1 │   │ 0  0  1 │   │ 1 │
└   ┘   └         ┘   └   ┘

展开计算:
x' = a*x + c*y + tx
y' = b*x + d*y + ty

代码实现:

typescript
// editor/utils/src/matrix.ts

class Matrix {
    a: number;
    b: number;
    c: number;
    d: number;
    tx: number;
    ty: number;

    /**
     * 将点 (x, y) 应用矩阵变换
     */
    apply(pos: IPointData, newPos: Point = new Point()): Point {
        const x = pos.x;
        const y = pos.y;

        newPos.x = this.a * x + this.c * y + this.tx;
        newPos.y = this.b * x + this.d * y + this.ty;

        return newPos;
    }
}

4.4 批量点变换

typescript
// infinite-renderer/src/utils/math.ts

/**
 * 将一组点批量应用矩阵变换
 * points 格式:[x0, y0, x1, y1, x2, y2, ...]
 */
export function transform(
    matrix: Matrix, 
    points: number[], 
    newPoints: number[] = []
): number[] {
    newPoints.length = points.length;
    
    for (let i = 0; i < points.length; i += 2) {
        const x = points[i];
        const y = points[i + 1];
        
        // 应用矩阵变换
        const newX = matrix.a * x + matrix.c * y + matrix.tx;
        const newY = matrix.b * x + matrix.d * y + matrix.ty;
        
        newPoints[i] = newX;
        newPoints[i + 1] = newY;
    }
    
    return newPoints;
}

为什么用扁平数组?

  • 内存连续,缓存友好
  • 减少对象创建开销
  • 便于与 WebGL 交互

4.5 基本变换的矩阵表示

单位矩阵(Identity)

┌       ┐
│ 1 0 0 │
│ 0 1 0 │      不做任何变换
│ 0 0 1 │      (x', y') = (x, y)
└       ┘
typescript
static readonly IDENTITY = new Matrix(1, 0, 0, 1, 0, 0);

identity(): Matrix {
    this.a = 1;
    this.b = 0;
    this.c = 0;
    this.d = 1;
    this.tx = 0;
    this.ty = 0;
    return this;
}

平移(Translation)

┌         ┐
│ 1  0  tx│
│ 0  1  ty│      x' = x + tx
│ 0  0  1 │      y' = y + ty
└         ┘
typescript
translate(x: number, y: number): Matrix {
    this.tx += x;
    this.ty += y;
    return this;
}

缩放(Scale)

┌          ┐
│ sx  0  0 │
│ 0  sy  0 │      x' = x * sx
│ 0   0  1 │      y' = y * sy
└          ┘
typescript
scale(x: number, y: number = x): Matrix {
    this.a *= x;
    this.b *= y;
    this.c *= x;
    this.d *= y;
    this.tx *= x;
    this.ty *= y;
    return this;
}

注意: 缩放会影响 tx, ty!因为缩放是在当前矩阵基础上追加的。

旋转(Rotation)

┌                    ┐
│ cos(θ)  -sin(θ)  0 │
│ sin(θ)   cos(θ)  0 │      x' = x*cos(θ) - y*sin(θ)
│   0        0     1 │      y' = x*sin(θ) + y*cos(θ)
└                    ┘
typescript
rotate(angle: number): Matrix {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    
    const a1 = this.a;
    const c1 = this.c;
    const tx1 = this.tx;
    
    this.a = a1 * cos - this.b * sin;
    this.b = a1 * sin + this.b * cos;
    this.c = c1 * cos - this.d * sin;
    this.d = c1 * sin + this.d * cos;
    this.tx = tx1 * cos - this.ty * sin;
    this.ty = tx1 * sin + this.ty * cos;
    
    return this;
}

五、矩阵运算的代码实现

5.1 Matrix 类完整实现

typescript
// editor/utils/src/matrix.ts

export class Matrix {
    public a: number;
    public b: number;
    public c: number;
    public d: number;
    public tx: number;
    public ty: number;

    static readonly IDENTITY = new Matrix();

    constructor(a = 1, b = 0, c = 0, d = 1, tx = 0, ty = 0) {
        this.a = a;
        this.b = b;
        this.c = c;
        this.d = d;
        this.tx = tx;
        this.ty = ty;
    }

    /**
     * 设置矩阵参数
     */
    set(a: number, b: number, c: number, d: number, tx: number, ty: number): Matrix {
        this.a = a;
        this.b = b;
        this.c = c;
        this.d = d;
        this.tx = tx;
        this.ty = ty;
        return this;
    }

    /**
     * 重置为单位矩阵
     */
    identity(): Matrix {
        return this.set(1, 0, 0, 1, 0, 0);
    }

    /**
     * 克隆矩阵
     */
    clone(): Matrix {
        return new Matrix(this.a, this.b, this.c, this.d, this.tx, this.ty);
    }

    /**
     * 从另一个矩阵复制
     */
    copyFrom(matrix: Matrix): Matrix {
        return this.set(matrix.a, matrix.b, matrix.c, matrix.d, matrix.tx, matrix.ty);
    }
}

5.2 矩阵乘法:追加变换

数学原理:

两个变换的组合 = 两个矩阵相乘

M_total = M_second × M_first

┌          ┐   ┌          ┐   ┌          ┐
│ a2 c2 tx2│   │ a1 c1 tx1│   │ a  c  tx │
│ b2 d2 ty2│ × │ b1 d1 ty1│ = │ b  d  ty │
│ 0  0   1 │   │ 0  0   1 │   │ 0  0  1  │
└          ┘   └          ┘   └          ┘

结果:
a  = a2*a1 + c2*b1
b  = b2*a1 + d2*b1
c  = a2*c1 + c2*d1
d  = b2*c1 + d2*d1
tx = a2*tx1 + c2*ty1 + tx2
ty = b2*tx1 + d2*ty1 + ty2

代码实现:

typescript
// editor/utils/src/matrix.ts

/**
 * 在当前矩阵之后追加变换
 * this = this × matrix
 */
append(matrix: Matrix): Matrix {
    const a1 = this.a;
    const b1 = this.b;
    const c1 = this.c;
    const d1 = this.d;
    const tx1 = this.tx;
    const ty1 = this.ty;

    const { a: a2, b: b2, c: c2, d: d2, tx: tx2, ty: ty2 } = matrix;

    this.a = a1 * a2 + c1 * b2;
    this.b = b1 * a2 + d1 * b2;
    this.c = a1 * c2 + c1 * d2;
    this.d = b1 * c2 + d1 * d2;
    this.tx = a1 * tx2 + c1 * ty2 + tx1;
    this.ty = b1 * tx2 + d1 * ty2 + ty1;

    return this;
}

/**
 * 在当前矩阵之前添加变换
 * this = matrix × this
 */
prepend(matrix: Matrix): Matrix {
    const { a: a2, b: b2, c: c2, d: d2, tx: tx2, ty: ty2 } = matrix;

    const a1 = this.a;
    const b1 = this.b;
    const c1 = this.c;
    const d1 = this.d;
    const tx1 = this.tx;
    const ty1 = this.ty;

    this.a = a2 * a1 + c2 * b1;
    this.b = b2 * a1 + d2 * b1;
    this.c = a2 * c1 + c2 * d1;
    this.d = b2 * c1 + d2 * d1;
    this.tx = a2 * tx1 + c2 * ty1 + tx2;
    this.ty = b2 * tx1 + d2 * ty1 + ty2;

    return this;
}

append vs prepend 的区别:

假设有变换 A 和 B:
- A.append(B):先 A 后 B,结果 = A × B
- A.prepend(B):先 B 后 A,结果 = B × A

矩阵乘法不满足交换律:A × B ≠ B × A

5.3 矩阵求逆

用途: 逆向变换。如果 M 将点 P 变换到 P',那么 M⁻¹ 将 P' 变换回 P。

typescript
// editor/utils/src/matrix.ts

/**
 * 计算逆矩阵
 */
invert(): Matrix {
    const a1 = this.a;
    const b1 = this.b;
    const c1 = this.c;
    const d1 = this.d;
    const tx1 = this.tx;
    const ty1 = this.ty;

    // 计算行列式
    const determinant = a1 * d1 - b1 * c1;

    if (determinant === 0) {
        // 矩阵不可逆,重置为单位矩阵
        return this.identity();
    }

    const invDet = 1 / determinant;

    this.a = d1 * invDet;
    this.b = -b1 * invDet;
    this.c = -c1 * invDet;
    this.d = a1 * invDet;
    this.tx = (c1 * ty1 - d1 * tx1) * invDet;
    this.ty = (b1 * tx1 - a1 * ty1) * invDet;

    return this;
}

/**
 * 应用逆变换
 */
applyInverse(pos: IPointData, newPos: Point = new Point()): Point {
    const a = this.a;
    const b = this.b;
    const c = this.c;
    const d = this.d;
    const tx = this.tx;
    const ty = this.ty;

    const x = pos.x;
    const y = pos.y;

    const determinant = a * d - b * c;
    
    if (determinant === 0) {
        newPos.x = 0;
        newPos.y = 0;
        return newPos;
    }

    const invDet = 1 / determinant;

    newPos.x = (d * (x - tx) - c * (y - ty)) * invDet;
    newPos.y = (a * (y - ty) - b * (x - tx)) * invDet;

    return newPos;
}

六、复合变换与实际应用

6.1 以某点为中心缩放

问题: 用户用滚轮缩放时,期望以鼠标位置为中心缩放。

错误的做法:

typescript
// 错误:直接改变 zoom
viewport.zoom = newZoom;
// 结果:画布以左上角为中心缩放,鼠标下的内容会"跑掉"

正确的做法:

步骤分解:
1. 将鼠标位置移到坐标原点
2. 执行缩放
3. 将原点移回鼠标位置

数学推导:

设:
- 鼠标位置为 (mx, my)
- 当前缩放为 z_old,目标缩放为 z_new
- 当前视口位置为 (vx_old, vy_old)

目标:计算新的视口位置 (vx_new, vy_new)

约束:鼠标位置在世界坐标系中保持不变

鼠标在世界坐标系中的位置(变换前):
    wx = (mx - vx_old) / z_old
    wy = (my - vy_old) / z_old

变换后,同一个世界坐标应该还在鼠标位置:
    mx = wx * z_new + vx_new
    my = wy * z_new + vy_new

解出 vx_new, vy_new:
    vx_new = mx - wx * z_new
           = mx - ((mx - vx_old) / z_old) * z_new
           = mx - (mx - vx_old) * (z_new / z_old)
           = mx * (1 - z_new/z_old) + vx_old * (z_new/z_old)

简化形式(使用矩阵):
    M = T(mx, my) × S(z_new/z_old) × T(-mx, -my)
    newPosition = M.apply(oldPosition)

代码实现:

typescript
// infinite-plugins/src/plugins/viewport-plugin/hooks/use-gesture.ts

function handleZoom(event: WheelEvent) {
    // 获取鼠标在视口中的位置
    const { left, top } = editor.containerRect;
    const x = event.clientX - left;
    const y = event.clientY - top;

    // 计算新的缩放值
    const { viewport } = editor;
    const deltaY = clamp(event.deltaY, -80, 80);
    const delta = (100 - deltaY * 0.6) / 100;
    const { minZoom, maxZoom } = editor.viewport.limit;
    const zoom = clamp(delta * viewport.zoom, minZoom, maxZoom);

    // 构建变换矩阵
    // 1. 移动到鼠标位置
    // 2. 缩放(先除以旧缩放,再乘以新缩放)
    // 3. 移动回来
    const matrix = Matrix.IDENTITY
        .translate(-x, -y)                              // 移到原点
        .scale(1 / viewport.zoom, 1 / viewport.zoom)   // 移除旧缩放
        .scale(zoom, zoom)                              // 应用新缩放
        .translate(x, y);                               // 移回

    // 计算新的视口位置
    const point = matrix.apply({
        x: editor.viewport.x,
        y: editor.viewport.y,
    });

    // 应用变换
    editor.viewport.setZoom(zoom);
    editor.viewport.setPosition(point.x, point.y);
}

6.2 滚动平移

相比缩放,平移简单得多:

typescript
// infinite-plugins/src/plugins/viewport-plugin/hooks/use-gesture.ts

function handleScroll(event: WheelEvent) {
    // 直接修改视口位置
    editor.viewport.translate(-event.deltaX, -event.deltaY);
}

// viewport.ts
translate(x: number, y: number): void {
    this.setPosition(this.position.x + x, this.position.y + y);
}

6.3 缩放到特定元素

场景: 双击某个元素,让它居中并适配屏幕显示。

typescript
// infinite-plugins/src/plugins/viewport-plugin/commands/zoom.ts

function zoomToElement(elementId: string, options: ZoomOptions = {}) {
    const { viewport } = editor;
    const { paddingRatio = 0.1, maxZoom: optMaxZoom, cubeBezier } = options;

    // 获取元素边界(世界坐标)
    const bounds = editor.getElementBounds(elementId);
    if (!bounds) return;

    const { minZoom, maxZoom: limitMaxZoom } = viewport.limit;
    const maxZoom = optMaxZoom ?? limitMaxZoom;
    const [top, right, bottom, left] = viewport.padding;

    // 计算可用视口尺寸(减去 padding)
    const availableWidth = viewport.clientWidth - left - right;
    const availableHeight = viewport.clientHeight - top - bottom;

    // 计算适合的缩放比例
    const zoomX = availableWidth / bounds.width * (1 - paddingRatio);
    const zoomY = availableHeight / bounds.height * (1 - paddingRatio);
    const zoom = clamp(Math.min(zoomX, zoomY), minZoom, maxZoom);

    // 计算居中位置
    const centerX = bounds.x + bounds.width / 2;
    const centerY = bounds.y + bounds.height / 2;

    const x = -centerX * zoom + (availableWidth / 2) + left;
    const y = -centerY * zoom + (availableHeight / 2) + top;

    // 带动画的过渡
    animate({
        x,
        y,
        zoom,
        cubeBezier,
    });
}

6.4 缩放到多个元素

typescript
// infinite-plugins/src/plugins/viewport-plugin/commands/zoom.ts

function zoomToFit(elementIds?: string[], options: ZoomOptions = {}) {
    const { viewport } = editor;
    
    // 如果没有指定元素,使用所有元素
    const ids = elementIds ?? editor.getAllElementIds();
    
    if (ids.length === 0) return;

    // 计算所有元素的联合边界
    let minX = Infinity, minY = Infinity;
    let maxX = -Infinity, maxY = -Infinity;
    
    for (const id of ids) {
        const bounds = editor.getElementBounds(id);
        if (!bounds) continue;
        
        minX = Math.min(minX, bounds.x);
        minY = Math.min(minY, bounds.y);
        maxX = Math.max(maxX, bounds.x + bounds.width);
        maxY = Math.max(maxY, bounds.y + bounds.height);
    }

    const unionBounds = {
        x: minX,
        y: minY,
        width: maxX - minX,
        height: maxY - minY,
    };

    // 使用 zoomToElement 的逻辑
    zoomToRect(unionBounds, options);
}

七、矩阵分解原理

7.1 为什么需要矩阵分解?

问题: 数据模型用矩阵存储变换(紧凑),但 UI 需要显示位置、缩放、旋转(直观)。

typescript
// 数据模型(紧凑)
interface ElementModel {
    localTransform: { a, b, c, d, tx, ty };
    width: number;
    height: number;
}

// UI 显示(直观)
// "位置: (100, 50), 缩放: 150%, 旋转: 45°"

矩阵分解: 从 6 个矩阵参数中提取出 position, scale, rotation, skew。

7.2 分解算法

typescript
// infinite-renderer/src/common/transform.ts

/**
 * 从局部变换矩阵中分解出 Transform 属性
 * 
 * @param localTransform 变换矩阵
 * @param width 元素宽度(用于计算锚点)
 * @param height 元素高度
 * @param transform 输出对象
 */
export function decomposeLocalTransform(
    localTransform: MatrixInit,
    width: number,
    height: number,
    transform = new Transform(),
): Transform {
    const { a, b, c, d, tx, ty } = localTransform;

    // 1. 计算中心点(变换锚点,默认在元素中心)
    const pivot = { x: 0.5 * width, y: 0.5 * height };

    // 2. 从矩阵提取倾斜角度
    //    skewX = -atan2(-c, d)  // 从 c, d 计算
    //    skewY = atan2(b, a)    // 从 a, b 计算
    let skewX = -Math.atan2(-c, d);
    let skewY = Math.atan2(b, a);

    // 3. 判断是纯旋转还是倾斜
    //    如果 skewX + skewY ≈ 0 或 ≈ 2π,则是纯旋转
    const delta = Math.abs(skewX + skewY);
    const PI_2 = Math.PI * 2;
    let rotation = 0;

    if (delta < 0.00001 || Math.abs(PI_2 - delta) < 0.00001) {
        // 纯旋转情况
        rotation = skewY;
        skewX = skewY = 0;
    } else {
        // 有倾斜的情况
        rotation = 0;
    }

    // 4. 从矩阵提取缩放
    //    scaleX = √(a² + b²)
    //    scaleY = √(c² + d²)
    const scaleX = Math.hypot(a, b);
    const scaleY = Math.hypot(c, d);

    // 5. 计算位置(考虑锚点偏移)
    //    由于元素以中心为锚点旋转/缩放,需要反推左上角位置
    const x = tx + pivot.x - (pivot.x * a + pivot.y * c);
    const y = ty + pivot.y - (pivot.x * b + pivot.y * d);

    // 6. 设置结果
    transform.position.set(x, y);
    transform.scale.set(scaleX, scaleY);
    transform.skew.set(skewX, skewY);
    transform.rotation = rotation;

    return transform;
}

7.3 数学推导

为什么 scaleX = √(a² + b²)

考虑一个缩放+旋转的矩阵:
┌                          ┐
│ sx*cos(θ)  -sy*sin(θ)  0 │
│ sx*sin(θ)   sy*cos(θ)  0 │
│     0           0      1 │
└                          ┘

其中:
a = sx*cos(θ)
b = sx*sin(θ)

所以:
√(a² + b²) = √(sx²cos²θ + sx²sin²θ)
           = √(sx²(cos²θ + sin²θ))
           = √(sx²)
           = sx

为什么 rotation = atan2(b, a)

继续上面的例子:
a = sx*cos(θ)
b = sx*sin(θ)

atan2(b, a) = atan2(sx*sin(θ), sx*cos(θ))
            = atan2(sin(θ), cos(θ))  // sx 约掉
            = θ

7.4 在渲染中的应用

typescript
// infinite-renderer/src/vms/base/base-element-vm.ts

updateTransform(model: P = this._model): void {
    // 更新可见性
    this.view.visible = !model.hidden && !model.$hidden;
    
    // 从模型的矩阵分解出变换参数,并应用到视图
    decomposeTransform(model, this.transform, this.view.transform);
    
    // 更新透明度
    this.updateOpacity(model);
}

// 这里 decomposeTransform 会:
// 1. 从 model.localTransform 分解出 position, scale, rotation
// 2. 设置到 this.view.transform(PixiJS 的 Transform)
// 3. PixiJS 会据此计算 worldTransform 用于渲染

八、包围盒与碰撞检测

8.1 什么是包围盒?

包围盒(Bounding Box): 能够完全包围图形的最小矩形。

用途:

  • 快速碰撞检测(粗筛)
  • 选区判断
  • 视口裁剪(只渲染可见元素)

8.2 AABB:轴对齐包围盒

定义: 边与坐标轴平行的包围盒。

        ┌─────────────────────────────┐
        │                             │
        │    ╱╲                       │
        │   ╱  ╲  ← 旋转的元素        │
        │  ╱    ╲                     │
        │ ╱      ╲                    │
        │╲        ╱                   │
        │ ╲      ╱                    │
        │  ╲    ╱                     │
        │   ╲  ╱                      │
        │    ╲╱                       │
        │                             │
        └─────────────────────────────┘
        ↑ AABB:边与 X/Y 轴平行

优点:

  • 计算简单(只需找 minX, maxX, minY, maxY)
  • 碰撞检测快速

缺点:

  • 元素旋转后,AABB 会变大
  • 精度低,可能有大量空白区域

计算实现:

typescript
// infinite-renderer/src/vms/base/base-element-vm.ts

getBounds(skipUpdate?: boolean, newRect = new Rectangle()): Rectangle {
    // 使用 PixiJS 的内置方法计算 AABB
    return this.view.getBounds(skipUpdate, newRect);
}

// PixiJS 内部逻辑:
// 1. 获取元素的四个顶点(世界坐标)
// 2. 找到 minX, maxX, minY, maxY
// 3. 返回 Rectangle(minX, minY, maxX-minX, maxY-minY)

8.3 OBB:有向包围盒

定义: 边与元素对齐的包围盒。

            ╱─────────────╲
           ╱               ╲
          ╱                 ╲
         ╱    ╱╲             ╲
        ╱    ╱  ╲  元素       ╲
       ╱    ╱    ╲             ╲
      ╱    ╱      ╲             ╲
      ╲   ╲        ╱            ╱
       ╲   ╲      ╱            ╱
        ╲   ╲    ╱            ╱
         ╲   ╲  ╱            ╱
          ╲   ╲╱            ╱
           ╲               ╱
            ╲─────────────╱
            ↑ OBB:边与元素边平行

优点:

  • 更紧凑,没有多余空白
  • 更精确的碰撞检测

缺点:

  • 计算复杂
  • 碰撞检测需要更多运算

8.4 OBB 的表示与计算

OBB 表示为: 位置 + 尺寸 + 旋转角度

typescript
interface OrientedRectangle {
    x: number;        // 左上角 X(旋转前的参考点)
    y: number;        // 左上角 Y
    width: number;    // 宽度
    height: number;   // 高度
    rotation: number; // 旋转角度(弧度)
}

从四个顶点计算 OBB:

typescript
// infinite-renderer/src/common/transform.ts

export function decomposeOrientedBounds(
    points: number[],  // [x0,y0, x1,y1, x2,y2, x3,y3] 四个顶点
    orientedRect = OrientedRectangle.EMPTY,
): OrientedRectangle {
    // 提取四个顶点
    const x0 = points[0], y0 = points[1];  // 第一个顶点
    const x1 = points[2], y1 = points[3];  // 第二个顶点
    const x3 = points[4], y3 = points[5];  // 对角顶点

    // 1. 计算第一条边与 X 轴的夹角
    //    这就是 OBB 的旋转角度
    const theta = angle(1, 0, x1 - x0, y1 - y0);

    // 2. 构建反向旋转矩阵
    //    将多边形"摆正",使边与坐标轴平行
    const matrix = tempMatrix1
        .identity()
        .translate(-x0, -y0)   // 移到原点
        .rotate(-theta)         // 反向旋转
        .translate(x0, y0);     // 移回

    // 3. 将对角顶点应用反向旋转
    const point = matrix.apply(tempPoint1.set(x3, y3), tempPoint2);

    // 4. 计算"摆正"后的宽高
    const width = point.x - x0;
    const height = point.y - y0;

    // 5. 返回 OBB
    orientedRect.x = x0;
    orientedRect.y = y0;
    orientedRect.width = width;
    orientedRect.height = height;
    orientedRect.rotation = theta;

    return orientedRect;
}

8.5 AABB 碰撞检测

两个 AABB 是否相交?

typescript
function aabbIntersects(a: Rectangle, b: Rectangle): boolean {
    // 两个矩形在 X 轴和 Y 轴上都有重叠才相交
    return a.x < b.x + b.width
        && a.x + a.width > b.x
        && a.y < b.y + b.height
        && a.y + a.height > b.y;
}

点是否在 AABB 内?

typescript
function aabbContains(rect: Rectangle, point: IPointData): boolean {
    return point.x >= rect.x
        && point.x <= rect.x + rect.width
        && point.y >= rect.y
        && point.y <= rect.y + rect.height;
}

8.6 OBB 碰撞检测:分离轴定理(SAT)

分离轴定理: 如果两个凸多边形不相交,则一定存在一条"分离轴",将两个多边形分开。

算法步骤:

  1. 对每个多边形的每条边,取其法向量作为投影轴
  2. 将两个多边形投影到该轴上
  3. 如果投影有间隔(不重叠),则多边形不相交
  4. 如果所有轴的投影都重叠,则多边形相交
typescript
// infinite-renderer/src/common/hit-test.ts

export function doesHitAreaIntersectRect(
    hitArea: Rectangle,
    rect: Rectangle,
    matrix: Matrix = IDENTITY,
): boolean {
    // 1. 获取 hitArea 经过变换后的四个顶点
    const vertices = getTransformedVertices(hitArea, matrix);
    
    // 2. 获取 rect 的四个顶点
    const rectVertices = getRectVertices(rect);
    
    // 3. 收集所有需要测试的分离轴
    //    包括两个多边形各边的法向量
    const axes = collectAxes(vertices, rectVertices);
    
    // 4. 对每个轴进行投影测试
    for (const axis of axes) {
        const projA = projectPolygon(vertices, axis);
        const projB = projectPolygon(rectVertices, axis);
        
        // 如果投影不重叠,找到分离轴,不相交
        if (!overlaps(projA, projB)) {
            return false;
        }
    }
    
    // 所有轴都重叠,相交
    return true;
}

九、向量运算与角度计算

9.1 向量基础

向量: 有方向和大小的量,用 (x, y) 表示。

向量 v = (3, 4)

    ↑ y

    │    ↗ v = (3, 4)
    │   ╱
    │  ╱ |v| = 5
    │ ╱
    └──────────→ x

向量长度(模):

|v| = √(x² + y²)

示例:|(3, 4)| = √(9 + 16) = √25 = 5
typescript
const length = Math.hypot(x, y);  // 等价于 Math.sqrt(x*x + y*y)

9.2 点积(Dot Product)

定义:

a · b = ax*bx + ay*by
      = |a| * |b| * cos(θ)

其中 θ 是两向量的夹角

几何意义: 一个向量在另一个向量方向上的投影长度 × 另一个向量的长度。

用途: 计算两向量夹角的大小(不含方向)。

typescript
const dot = ax * bx + ay * by;
const cosTheta = dot / (Math.hypot(ax, ay) * Math.hypot(bx, by));
const theta = Math.acos(cosTheta);

9.3 叉积(Cross Product)

2D 叉积(伪叉积):

a × b = ax*by - ay*bx

结果是一个标量(在 3D 中是 z 分量)

几何意义: 两向量构成的平行四边形的有符号面积。

  • 结果 > 0:b 在 a 的逆时针方向
  • 结果 < 0:b 在 a 的顺时针方向
  • 结果 = 0:a 和 b 平行

用途: 判断角度的方向(顺时针/逆时针)。

9.4 完整的夹角计算

typescript
// infinite-renderer/src/utils/math.ts

/**
 * 计算从向量 a 到向量 b 的有符号夹角
 * 
 * @param ax 向量 a 的 x 分量
 * @param ay 向量 a 的 y 分量
 * @param bx 向量 b 的 x 分量
 * @param by 向量 b 的 y 分量
 * @returns 夹角(弧度),正值为逆时针,负值为顺时针
 */
export function angle(ax: number, ay: number, bx: number, by: number): number {
    // 1. 计算两向量的模
    const am = Math.hypot(ax, ay);
    const bm = Math.hypot(bx, by);

    // 2. 使用点积公式计算夹角余弦
    //    cos(θ) = (a · b) / (|a| × |b|)
    const dot = ax * bx + ay * by;
    const cosTheta = dot / (am * bm);
    
    // 3. 使用 acos 获取夹角大小(0 到 π)
    const theta = Math.acos(clamp(cosTheta, -1, 1));  // clamp 防止浮点误差

    // 4. 使用叉积判断方向
    //    z = ax*by - ay*bx
    const z = ax * by - ay * bx;
    const sign = z >= 0 ? 1 : -1;

    // 5. 返回有符号角度
    return theta * sign;
}

应用场景:

typescript
// 计算旋转手柄的旋转角度
function getRotationAngle(center: Point, start: Point, current: Point): number {
    // 起始向量
    const startVec = { x: start.x - center.x, y: start.y - center.y };
    // 当前向量
    const currVec = { x: current.x - center.x, y: current.y - center.y };
    
    // 计算两向量的夹角
    return angle(startVec.x, startVec.y, currVec.x, currVec.y);
}

9.5 判断点在线段的哪一侧

typescript
// infinite-renderer/src/utils/math.ts

/**
 * 计算点相对于有向线段的方向
 * 
 * @returns 正值:点在线段左侧
 *          负值:点在线段右侧
 *          零:点在线段上
 */
export function orientation(
    ax: number, ay: number,  // 线段起点
    bx: number, by: number,  // 线段终点
    cx: number, cy: number,  // 待判断的点
): number {
    // 向量 AB
    const abx = bx - ax;
    const aby = by - ay;
    
    // 向量 AC
    const acx = cx - ax;
    const acy = cy - ay;
    
    // AB × AC(叉积)
    return abx * acy - aby * acx;
}

用途: 判断点是否在多边形内部。


十、实战案例分析

10.1 案例一:鼠标点击选中元素

完整流程:

typescript
// 1. 监听点击事件
canvas.addEventListener('click', (event) => {
    // 2. 屏幕坐标 → 视口坐标
    const viewportX = event.clientX - containerRect.left;
    const viewportY = event.clientY - containerRect.top;
    
    // 3. 视口坐标 → 世界坐标
    const worldPoint = viewport.getLocalPoint({ x: viewportX, y: viewportY });
    
    // 4. 碰撞检测
    const hitElement = surface.hitTest(worldPoint);
    
    // 5. 更新选中状态
    if (hitElement) {
        editor.select(hitElement.id);
    } else {
        editor.clearSelection();
    }
});

10.2 案例二:框选多个元素

typescript
// 1. 记录框选起点(世界坐标)
let startPoint: Point;
canvas.addEventListener('mousedown', (event) => {
    const viewportPoint = { x: event.clientX - containerRect.left, y: event.clientY - containerRect.top };
    startPoint = viewport.getLocalPoint(viewportPoint);
});

// 2. 绘制选区矩形
canvas.addEventListener('mousemove', (event) => {
    if (!startPoint) return;
    
    const viewportPoint = { x: event.clientX - containerRect.left, y: event.clientY - containerRect.top };
    const currentPoint = viewport.getLocalPoint(viewportPoint);
    
    // 计算选区矩形
    const selectionRect = new Rectangle(
        Math.min(startPoint.x, currentPoint.x),
        Math.min(startPoint.y, currentPoint.y),
        Math.abs(currentPoint.x - startPoint.x),
        Math.abs(currentPoint.y - startPoint.y)
    );
    
    // 绘制选区框(视口坐标)
    drawSelectionRect(selectionRect);
});

// 3. 完成框选
canvas.addEventListener('mouseup', () => {
    // 使用 intersect 找到与选区相交的所有元素
    const elements = surface.intersect(selectionRect);
    editor.selectMultiple(elements.map(e => e.id));
});

10.3 案例三:拖拽移动元素

typescript
let dragStart: Point;
let elementStartPos: Point;

element.addEventListener('mousedown', (event) => {
    // 记录拖拽起点(视口坐标)和元素初始位置
    dragStart = { x: event.clientX, y: event.clientY };
    elementStartPos = { x: element.model.left, y: element.model.top };
});

document.addEventListener('mousemove', (event) => {
    if (!dragStart) return;
    
    // 计算屏幕坐标的偏移量
    const deltaScreen = {
        x: event.clientX - dragStart.x,
        y: event.clientY - dragStart.y,
    };
    
    // 转换为世界坐标的偏移量(需要除以缩放)
    const deltaWorld = {
        x: deltaScreen.x / viewport.zoom,
        y: deltaScreen.y / viewport.zoom,
    };
    
    // 更新元素位置
    element.model.left = elementStartPos.x + deltaWorld.x;
    element.model.top = elementStartPos.y + deltaWorld.y;
});

10.4 案例四:旋转元素

typescript
// 1. 获取元素中心点(世界坐标)
const center = {
    x: element.model.left + element.model.width / 2,
    y: element.model.top + element.model.height / 2,
};

// 2. 记录起始角度
let startAngle: number;
rotateHandle.addEventListener('mousedown', (event) => {
    const worldPoint = viewport.getLocalPoint({
        x: event.clientX - containerRect.left,
        y: event.clientY - containerRect.top,
    });
    
    // 从中心到点击位置的向量
    startAngle = Math.atan2(worldPoint.y - center.y, worldPoint.x - center.x);
});

// 3. 计算旋转角度
document.addEventListener('mousemove', (event) => {
    if (startAngle === undefined) return;
    
    const worldPoint = viewport.getLocalPoint({
        x: event.clientX - containerRect.left,
        y: event.clientY - containerRect.top,
    });
    
    // 当前角度
    const currentAngle = Math.atan2(worldPoint.y - center.y, worldPoint.x - center.x);
    
    // 角度差
    const deltaAngle = currentAngle - startAngle;
    
    // 更新元素旋转
    element.model.rotation += deltaAngle;
    
    // 更新起始角度(增量旋转)
    startAngle = currentAngle;
});

本章小结

核心概念

概念说明
三个坐标系屏幕 → 视口 → 世界,层层转换
视口状态x, y, zoom 三个参数定义视图
仿射矩阵6 参数表示平移、缩放、旋转、倾斜
矩阵乘法append/prepend 组合多个变换
矩阵分解从矩阵提取 position/scale/rotation
AABB轴对齐包围盒,简单但不精确
OBB有向包围盒,精确但复杂
向量点积计算夹角大小
向量叉积判断夹角方向

关键公式

视口坐标 → 世界坐标:
    worldX = (viewportX - viewport.x) / zoom
    worldY = (viewportY - viewport.y) / zoom

世界坐标 → 视口坐标:
    viewportX = worldX * zoom + viewport.x
    viewportY = worldY * zoom + viewport.y

以点 P 为中心缩放:
    M = T(P) × S(scale) × T(-P)

向量夹角:
    cos(θ) = (a·b) / (|a|×|b|)
    方向 = sign(a×b)

关键代码文件

文件职责
editor/utils/src/matrix.tsMatrix 类实现
infinite-renderer/src/utils/math.ts数学工具函数
infinite-renderer/src/common/transform.ts矩阵分解、OBB 计算
infinite-renderer/src/viewport/viewport.ts坐标转换 API
infinite-plugins/src/plugins/viewport-plugin/hooks/use-gesture.ts手势处理
infinite-plugins/src/plugins/viewport-plugin/commands/zoom.ts缩放命令

下一章第4章:Viewport 视口管理 —— 理解渲染架构的顶层设计

如有转载或 CV 的请标注本站原文地址