第3章:坐标系统与矩阵变换
教学目标: 深入理解无限画布的坐标系统与矩阵变换原理,从数学本质到代码实现,从单点变换到复杂场景应用的完整技术链路。
📚 目录
一、为什么需要理解坐标系统?
1.1 一个直观的问题
想象一个场景:用户用鼠标点击了屏幕上的某个位置,你需要判断他点击了哪个图形元素。
听起来简单?但请考虑:
- 画布被缩放到了 150%
- 用户平移了画布,向右移动了 200 像素
- 被点击的图形本身还旋转了 45 度
- 图形还嵌套在一个组内,组也有自己的变换
鼠标事件给你的是 event.clientX = 523, event.clientY = 312。
问题:这个点对应画布上的哪个坐标?这个坐标落在哪个图形内?
这就是坐标系统要解决的核心问题。
1.2 无限画布的"无限"来自哪里?
传统画布有固定尺寸:
// 传统Canvas
<canvas width="1920" height="1080" />画布就这么大,画出去就看不见了。
无限画布的秘密: 画布本身还是有限的(屏幕就那么大),但通过坐标变换,我们可以"观察"无限大的空间。
┌─────────────────────────────────────────────────────────────┐
│ │
│ 无限大的世界空间 │
│ ↑ │
│ ┌────────┐ │ ┌────────┐ │
│ │ 元素A │ │ │ 元素B │ │
│ └────────┘ │ └────────┘ │
│ │ │
│ ←────────────────────┼────────────────────→ │
│ │ │
│ ┌────────┐ ↓ ┌────────┐ │
│ │ 元素C │ │ 元素D │ │
│ └────────┘ └────────┘ │
│ │
│ ╔═══════════════╗ │
│ ║ 视口窗口 ║ ← 用户实际看到的区域 │
│ ║ (有限大小) ║ │
│ ╚═══════════════╝ │
│ │
└─────────────────────────────────────────────────────────────┘本质: 无限画布 = 有限视口 + 坐标变换。
1.3 本章要解决的问题
读完本章,你将能够回答:
- 用户点击屏幕 (523, 312),对应世界空间的哪个坐标?
- 世界空间的元素 (1000, 2000),在屏幕上的哪个位置?
- 以鼠标位置为中心缩放,需要怎么计算新位置?
- 如何判断一个点是否在旋转后的矩形内?
- 如何从变换矩阵中提取出旋转角度和缩放比例?
二、三个坐标系的深度解析
2.1 屏幕坐标系(Screen Coordinates)
定义: 以浏览器视口左上角为原点的坐标系。
┌─────────────────────────────────────────────────────────┐
│ ← 浏览器窗口 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 地址栏、工具栏等 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ (0, 0) │ │
│ │ ↓ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ 页面内容 │ │ │
│ │ │ │ │ │
│ │ │ ● (523, 312) ← 鼠标事件坐标 │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘获取方式:
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) │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ └─────────────────────┘ │ │
│ └──────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────┘从屏幕坐标转换:
// 获取画布容器的位置
const containerRect = canvasContainer.getBoundingClientRect();
// 屏幕坐标 → 视口坐标
const viewportX = event.clientX - containerRect.left;
const viewportY = event.clientY - containerRect.top;为什么需要这一步?
画布容器不一定在页面左上角。可能有侧边栏、工具栏等其他元素。视口坐标让我们专注于画布区域内的相对位置。
代码位置:
// 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
│ │ │ (世界坐标)
│ └─────────┘
│
↓关键特性:
- 不变性: 无论用户如何缩放、平移画布,元素在世界坐标系中的位置不变
- 逻辑性: 元素的
left,top,width,height都是世界坐标 - 无限性: 没有边界限制,可以放置在任意位置
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 视口状态的三个参数
视口由三个参数定义:
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代码实现:
// 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代码实现:
// 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 一个完整的例子
// 场景设置
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 只能表示简单的平移和缩放。如果还有旋转呢?
// 简单方案(不支持旋转)
interface SimpleTransform {
x: number;
y: number;
zoom: number;
}
// 如何表示旋转?
// 如何表示倾斜?
// 如何组合多个变换?答案: 使用变换矩阵,可以统一表示所有 2D 仿射变换。
4.2 2D 仿射变换矩阵的结构
┌ ┐
│ a c tx │
│ b d ty │
│ 0 0 1 │
└ ┘6 个有效参数的含义:
| 参数 | 几何含义 | 单位矩阵值 | 变换公式 |
|---|---|---|---|
a | X 轴缩放 + X 方向的水平剪切 | 1 | newX 中 x 的系数 |
b | Y 轴缩放 + Y 方向的垂直剪切 | 0 | newY 中 x 的系数 |
c | X 轴倾斜 | 0 | newX 中 y 的系数 |
d | Y 轴缩放 | 1 | newY 中 y 的系数 |
tx | X 轴平移 | 0 | newX 的常数项 |
ty | Y 轴平移 | 0 | newY 的常数项 |
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代码实现:
// 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 批量点变换
// 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)
└ ┘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
└ ┘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
└ ┘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(θ)
└ ┘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 类完整实现
// 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代码实现:
// 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 × A5.3 矩阵求逆
用途: 逆向变换。如果 M 将点 P 变换到 P',那么 M⁻¹ 将 P' 变换回 P。
// 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 以某点为中心缩放
问题: 用户用滚轮缩放时,期望以鼠标位置为中心缩放。
错误的做法:
// 错误:直接改变 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)代码实现:
// 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 滚动平移
相比缩放,平移简单得多:
// 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 缩放到特定元素
场景: 双击某个元素,让它居中并适配屏幕显示。
// 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 缩放到多个元素
// 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 需要显示位置、缩放、旋转(直观)。
// 数据模型(紧凑)
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 分解算法
// 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 在渲染中的应用
// 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 会变大
- 精度低,可能有大量空白区域
计算实现:
// 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 表示为: 位置 + 尺寸 + 旋转角度
interface OrientedRectangle {
x: number; // 左上角 X(旋转前的参考点)
y: number; // 左上角 Y
width: number; // 宽度
height: number; // 高度
rotation: number; // 旋转角度(弧度)
}从四个顶点计算 OBB:
// 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 是否相交?
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 内?
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)
分离轴定理: 如果两个凸多边形不相交,则一定存在一条"分离轴",将两个多边形分开。
算法步骤:
- 对每个多边形的每条边,取其法向量作为投影轴
- 将两个多边形投影到该轴上
- 如果投影有间隔(不重叠),则多边形不相交
- 如果所有轴的投影都重叠,则多边形相交
// 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 = 5const length = Math.hypot(x, y); // 等价于 Math.sqrt(x*x + y*y)9.2 点积(Dot Product)
定义:
a · b = ax*bx + ay*by
= |a| * |b| * cos(θ)
其中 θ 是两向量的夹角几何意义: 一个向量在另一个向量方向上的投影长度 × 另一个向量的长度。
用途: 计算两向量夹角的大小(不含方向)。
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 完整的夹角计算
// 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;
}应用场景:
// 计算旋转手柄的旋转角度
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 判断点在线段的哪一侧
// 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 案例一:鼠标点击选中元素
完整流程:
// 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 案例二:框选多个元素
// 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 案例三:拖拽移动元素
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 案例四:旋转元素
// 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.ts | Matrix 类实现 |
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 视口管理 —— 理解渲染架构的顶层设计
