第7章:事件系统与碰撞检测
本章概要
核心问题:如何精确判断用户点击了哪个元素?
在图形编辑器中,当用户点击画布时,系统需要快速准确地判断点击位置对应哪个图形元素。这涉及到两个核心技术:
- 事件边界(EventBoundary):管理整个碰撞检测系统,协调元素遍历和检测策略
- 碰撞检测算法:实际判断点/矩形与元素是否相交的数学方法
本章将深入分析这两大系统的设计与实现。
目录
7.1 EventBoundary 事件边界
7.1.1 核心职责
EventBoundary 是无限画布中事件系统的核心组件,负责:
┌─────────────────────────────────────────────────────────────┐
│ EventBoundary 职责 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 点击检测(hitTest) │
│ ├── 判断点击位置命中哪个元素 │
│ └── 支持多种检测精度(元素/包围盒/有向包围盒) │
│ │
│ 2. 区域检测(intersect) │
│ ├── 查找与矩形区域相交的所有元素 │
│ └── 用于框选、碰撞判断等场景 │
│ │
│ 3. 遮罩管理(masks) │
│ ├── 管理覆盖遮罩集合 │
│ └── 遮罩区域阻断点击事件穿透 │
│ │
│ 4. 遍历控制(Bypass) │
│ ├── 控制元素的检测行为 │
│ └── 支持 Skip/Pass/Hits/None 策略 │
│ │
└─────────────────────────────────────────────────────────────┘7.1.2 架构设计
EventBoundary 的类结构:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
export interface EventBoundaryOptions {
viewport: IViewport<IPageVm>;
}
export class EventBoundary implements IEventBoundary {
// 视口引用,用于获取页面元素树
private viewport: IViewport<IPageVm>;
// 覆盖遮罩集合
private masks: Set<MaskTarget> = new Set();
constructor(options: EventBoundaryOptions) {
const { viewport } = options;
this.viewport = viewport;
}
// 公开 API
hitTest(point: IPointData | Rectangle, test?: TestFn): IBaseElementVm | null;
hitTestBounds(point: IPointData | Rectangle, test?: TestFn): IBaseElementVm | null;
hitTestOrientedBounds(point: IPointData, test?: TestFn): IBaseElementVm | null;
intersect(rect: Rectangle, test?: TestFn): IBaseElementVm[];
intersectBounds(rect: Rectangle, test?: TestFn): IBaseElementVm[];
intersectOrientedBounds(rect: Rectangle, test?: TestFn): IBaseElementVm[];
// 遮罩管理
addCoverMask(mask: MaskTarget): void;
removeCoverMask(mask: MaskTarget): void;
}设计要点:
- 依赖 Viewport:通过 viewport 获取页面的元素树(
viewport.page.children) - 遮罩集合:使用
Set存储,支持动态添加/移除 - 泛型检测函数:
TestFn允许外部控制每个元素的检测行为
7.1.3 递归遍历策略
EventBoundary 采用深度优先、从后向前的递归遍历策略:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
private hitTestRecursive<T extends IPointData | Rectangle>(
point: T,
element: IBaseElementVm,
hitTest: HitTest<T>,
test: TestFn<IBaseElementVm>,
): IBaseElementVm | null {
const bypass = test(element);
// 1. Skip 策略:跳过元素及其子元素
if (bypass === Bypass.Skip) {
return null;
}
// 2. 先检测当前元素是否命中
if (!hitTest(point, element)) {
return null;
}
// 3. Hits 策略:直接返回当前元素,不再检测子元素
if (bypass === Bypass.Hits) {
return element;
}
// 4. 递归检测子元素(从后向前,后绘制的在上层)
for (let i = element.children.length - 1; i >= 0; i--) {
const child = element.children[i];
const hits = this.hitTestRecursive(point, child, hitTest, test);
if (hits) {
return hits;
}
}
// 5. Pass 策略:穿透当前元素
if (bypass === Bypass.Pass) {
return null;
}
// 6. None 策略:返回当前元素
return element;
}遍历顺序图示:
元素层级(绘制顺序从下到上):
┌──────────────────────────────────────┐
│ Page │
│ ├── Element A (index: 0, 最先绘制) │
│ │ ├── A1 │
│ │ └── A2 │
│ ├── Element B (index: 1) │
│ │ ├── B1 │
│ │ └── B2 │
│ └── Element C (index: 2, 最后绘制) │ ← 最上层
│ ├── C1 │
│ └── C2 │
└──────────────────────────────────────┘
hitTest 遍历顺序:
1. 从 Page.children 末尾开始 → Element C
2. 递归进入 C 的子元素 → C2, C1(从后向前)
3. 若 C 未命中,继续 → Element B
4. 递归进入 B 的子元素 → B2, B1
5. 若 B 未命中,继续 → Element A
6. 递归进入 A 的子元素 → A2, A1为什么从后向前遍历?
在图形渲染中,后添加的元素绑定在上层。用户点击时,期望命中视觉上最上层的元素。从后向前遍历确保上层元素优先被检测到。
7.2 点击检测 hitTest
7.2.1 检测类型分层
系统提供三种精度的点击检测:
检测精度层级:
┌────────────────────────────────────────────────────────────┐
│ │
│ Level 1: hitTestBounds(包围盒检测) │
│ ┌─────────────────────────────────────┐ │
│ │ ┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ AABB 包围盒 │
│ │ │ │ │ 最快但最不精确 │
│ │ │ ╱╲ │ │ │
│ │ │ ╱ ╲ │ │ │
│ │ │ ╱ ╲ │ │ │
│ │ │ ╱______╲ │ │ │
│ │ └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │
│ └─────────────────────────────────────┘ │
│ │
│ Level 2: hitTestOrientedBounds(有向包围盒检测) │
│ ┌─────────────────────────────────────┐ │
│ │ ╱───────────────╲ │ OBB 包围盒 │
│ │ ╱ ╱╲ ╲ │ 考虑旋转 │
│ │ ╱ ╱ ╲ ╲ │ 中等精度 │
│ │ ╱ ╱ ╲ ╲ │ │
│ │ ╱ ╱______╲ ╲ │ │
│ │ ╲_______________________________________________╱ │
│ └─────────────────────────────────────┘ │
│ │
│ Level 3: hitTest(元素精确检测) │
│ ┌─────────────────────────────────────┐ │
│ │ ╱╲ │ 精确形状 │
│ │ ╱ ╲ │ 考虑 hitArea/mask │
│ │ ╱ ╲ │ 最精确但最慢 │
│ │ ╱______╲ │ │
│ └─────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘7.2.2 元素级点击检测
最精确的检测方式,考虑元素的 hitArea 和 mask:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
// 检测函数定义
const hitTestElement: HitTest = (point, element) => element.contains(point);
const hitTestElementByRect: HitTest<Rectangle> = (rect, element) => element.hits(rect);
// 公开 API
hitTest(point: IPointData | Rectangle, test: TestFn = defaultTest): IBaseElementVm | null {
if (point instanceof Rectangle) {
return this._hitTest(point, hitTestElementByRect, test);
}
return this._hitTest(point, hitTestElement, test);
}元素的 contains 方法实现:
// 来源:infinite-renderer/src/vms/base/base-element-vm.ts
contains(point: IPointData): boolean {
// 1. 若元素有 mask,优先检测 mask
if (this.view.mask) {
const maskObject = (this.view.mask as MaskData)._isMaskData
? (this.view.mask as MaskData).maskObject
: (this.view.mask as Graphics);
// 图形 Mask - 检测点是否在遮罩区域内
if (maskObject instanceof Graphics) {
return !!maskObject.containsPoint(point);
}
}
// 2. 若元素有 hitArea,检测 hitArea
if (this.view.hitArea) {
// 将世界坐标转换为元素本地坐标
const pos = this.getLocalPoint(point, tempPoint);
return !!this.view.hitArea.contains(pos.x, pos.y);
}
// 3. 默认返回 false(需子类覆写)
return false;
}检测流程图:
contains(point) 检测流程:
┌──────────────┐
│ 输入点坐标 │
└──────┬───────┘
│
▼
┌──────────────┐
│ 有 mask? │
└──────┬───────┘
Yes │ No
┌──────┴──────┐
▼ │
┌──────────────┐ │
│ mask 包含点? │ │
└──────┬───────┘ │
Yes │ No │
│ │ │
▼ ▼ ▼
true false ┌──────────────┐
│ 有 hitArea? │
└──────┬───────┘
Yes │ No
┌──────┴──────┐
▼ ▼
┌──────────────┐ false
│ 坐标转换到 │
│ 本地坐标系 │
└──────┬───────┘
▼
┌──────────────┐
│hitArea包含点?│
└──────┬───────┘
Yes │ No
│ │
▼ ▼
true false7.2.3 包围盒检测
使用 AABB(Axis-Aligned Bounding Box)进行快速检测:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
const tempRect = new Rectangle();
// 点检测
const hitTestBounds: HitTest = (point, element) => {
const bounds = element.getBounds(false, tempRect);
return bounds.contains(point.x, point.y);
};
// 矩形检测
const hitTestBoundsByRect: HitTest<Rectangle> = (rect, element) => {
const bounds = element.getBounds(false, tempRect);
return bounds.intersects(rect);
};AABB 原理图示:
AABB(轴对齐包围盒):
原始图形: AABB 包围盒:
╱╲ ┌─────────┐
╱ ╲ │ ╱╲ │
╱ ╲ │ ╱ ╲ │
╱ ╲ │╱ ╲ │
╱________╲ │╲____╱ │
└─────────┘
旋转后的图形: AABB 仍然轴对齐:
╱╲ ┌───────────┐
╱ ╲ │ ╱╲ │
╱ ╲ │ ╱ ╲ │
╱ ╲ │ ╱ ╲ │
╱________╲ │ ╱______╲ │
└───────────┘
↑ AABB 变大了!AABB 相交判定:
// Rectangle.contains(x, y) - 点包含检测
contains(x: number, y: number): boolean {
if (this.width <= 0 || this.height <= 0) return false;
return x >= this.x
&& x < this.x + this.width
&& y >= this.y
&& y < this.y + this.height;
}
// Rectangle.intersects(rect) - 矩形相交检测
intersects(rect: Rectangle): boolean {
if (this.width <= 0 || this.height <= 0 || rect.width <= 0 || rect.height <= 0) {
return false;
}
// 两个轴对齐矩形相交的充要条件
return this.x < rect.x + rect.width
&& this.x + this.width > rect.x
&& this.y < rect.y + rect.height
&& this.y + this.height > rect.y;
}7.2.4 有向包围盒检测
使用 OBB(Oriented Bounding Box)处理旋转元素:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
const POLYGON = new Polygon();
const hitTestOrientedBounds: HitTest = (point, element) => {
// 获取元素的有向多边形顶点
element.getOrientedPolygon(false, POLYGON.points);
// 判断点是否在多边形内
return POLYGON.contains(point.x, point.y);
};OBB vs AABB 对比:
同一旋转矩形的两种包围盒:
AABB(轴对齐): OBB(有向):
┌─────────────────────┐ ╱─────────────╲
│ ╱──────╲ │ ╱ ╲
│ ╱ ╲ │ ╱ ╲
│ ╱ ╲ │ ╱ ╲
│ ╱ ╲ │ ╱ ╲
│ ╱ ╲ │ ╱ ╲
│ ╱ ╲ │ ╲ ╱
│╱──────────────────╲ │ ╲ ╱
└─────────────────────┘ ╲ ╱
╲ ╱
╲ ╱
╲─────────────╱
AABB 面积: 大 OBB 面积: 精确
检测速度: 快 检测速度: 稍慢
误判率: 高 误判率: 低点在多边形内判定(射线法):
// Polygon.contains - 使用射线法判断点是否在多边形内
contains(x: number, y: number): boolean {
let inside = false;
const points = this.points;
const length = points.length / 2;
for (let i = 0, j = length - 1; i < length; j = i++) {
const xi = points[i * 2];
const yi = points[i * 2 + 1];
const xj = points[j * 2];
const yj = points[j * 2 + 1];
// 射线与边相交判定
const intersect = ((yi > y) !== (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
}射线法原理图示:
射线法判断点在多边形内:
从点 P 向右发射水平射线,
计算与多边形边的交点数:
- 奇数次交点 → 点在内部
- 偶数次交点 → 点在外部
┌────────────────┐
│ │
│ P1 ●────────┼────→ 交点数: 1(奇数)→ 内部
│ │
│ │
──────┼────────────────┼──────
│ │
P2 ●──┼────────────────┼────→ 交点数: 2(偶数)→ 外部
│ │
└────────────────┘7.3 区域检测 intersect
7.3.1 框选场景分析
区域检测主要用于框选功能:
框选操作示意:
mousedown mousemove mouseup
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ │
│ ┌─────────────────────┐ │
│ │ 框选矩形 │ │
│ │ ┌────┐ │ │
│ │ │ A │ ┌────┐ │ ┌────┐ │
│ │ └────┘ │ B │ │ │ C │ │
│ │ └────┘ │ └────┘ │
│ └─────────────────────┘ │
│ │
└──────────────────────────────────────────┘
intersect(框选矩形) → [Element A, Element B]
Element C 不在框选范围内7.3.2 相交判定策略
与点击检测类似,区域检测也提供三种精度:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
// 元素级相交判定
const intersectsElement: Intersects = (rect, element) => element.intersects(rect);
// 包围盒相交判定
const intersectsBounds: Intersects = (rect, element) => {
const bounds = element.getBounds(false, tempRect);
return bounds.intersects(rect);
};
// 有向包围盒相交判定(使用 SAT 算法)
const intersectsOrientedBounds: Intersects = (rect, element) => {
const points = element.getOrientedPolygon(false, POLYGON.points);
const rectPoints = getPoints(rect, tempPoints);
return doPolygonsIntersectBySAT(points, rectPoints);
};元素级 intersects 方法:
// 来源:infinite-renderer/src/vms/base/base-element-vm.ts
intersects(rect: Rectangle): boolean {
const bounds = this.view.getBounds(false, RECTANGLE);
// 1. 快速剔除:包围盒不相交则直接返回 false
if (!rect.intersects(bounds)) {
return false;
}
// 2. 快速判定:框选完全包含元素包围盒
if (rect.containsRect(bounds)) {
return true;
}
// 3. 精确判定:检测 mask
if (this.view.mask) {
const maskObject = (this.view.mask as MaskData)._isMaskData
? (this.view.mask as MaskData).maskObject
: (this.view.mask as Graphics);
// Graphics mask
if (maskObject instanceof Graphics) {
if (doesGraphicsIntersectRect(maskObject, rect)) {
return true;
}
}
// Sprite mask
if (maskObject instanceof Sprite) {
if (doesSpriteIntersectRect(maskObject, rect)) {
return true;
}
}
}
// 4. 精确判定:检测 hitArea
if (this.view.hitArea && this.view.hitArea instanceof Rectangle) {
return doesHitAreaIntersectRect(
this.view.hitArea,
rect,
this.view.worldTransform
);
}
return false;
}7.3.3 单次检测与批量检测
系统提供两种区域检测模式:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
// 批量检测:返回所有相交元素
intersect(rect: Rectangle, test: TestFn = defaultTest): IBaseElementVm[] {
return this._intersect(rect, intersectsElement, test);
}
// 单次检测:返回第一个相交元素
intersectOnce(rect: Rectangle, test: TestFn = defaultTest): IBaseElementVm | null {
return this._intersectOnce(rect, hitTestElementByRect, test);
}
// 批量检测内部实现
private _intersect(
rect: Rectangle,
intersects: Intersects,
test: TestFn = defaultTest,
): IBaseElementVm[] {
const { children } = this.viewport.page;
const results: IBaseElementVm[] = [];
// 从后向前遍历
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
this.intersectsRecursive(rect, child, intersects, test, results);
}
return results;
}递归相交检测:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
private intersectsRecursive(
rect: Rectangle,
element: IBaseElementVm,
intersects: Intersects,
test: TestFn<IBaseElementVm>,
results: IBaseElementVm[],
): void {
const bypass = test(element);
// Skip:跳过元素
if (bypass === Bypass.Skip) {
return;
}
// 不相交则返回
if (!intersects(rect, element)) {
return;
}
// 非 Pass 策略:将元素加入结果
if (bypass !== Bypass.Pass) {
results.push(element);
}
// Hits:命中后不再检测子元素
if (bypass === Bypass.Hits) {
return;
}
// None:继续递归检测子元素
if (bypass === Bypass.None) {
for (let i = element.children.length - 1; i >= 0; i--) {
const child = element.children[i];
this.intersectsRecursive(rect, child, intersects, test, results);
}
}
}hitTest vs intersect 对比:
| 特性 | hitTest | intersect |
|---|---|---|
| 返回值 | 单个元素或 null | 元素数组 |
| 找到后 | 立即返回 | 继续检测 |
| 应用场景 | 点击选择 | 框选 |
| 时间复杂度 | O(n) 最坏 | O(n) |
7.4 SAT 分离轴算法
7.4.1 算法原理
分离轴定理(Separating Axis Theorem):如果两个凸多边形不相交,则必定存在一条分离轴,使得两个多边形在该轴上的投影不重叠。
SAT 算法直观理解:
想象一个光源从不同角度照射两个物体,
观察它们在墙上的影子(投影):
角度 1:投影重叠
┌───┐ ┌───┐
│ A │ │ B │
└───┘ └───┘
━━━━━▇▇▇━━━━▇▇▇━━━━━ ← 墙(投影轴)
↑ ↑
影子A 影子B
影子重叠!需要继续检测其他角度...
角度 2:投影分离
┌───┐
│ A │
└───┘ ┌───┐
│ B │
└───┘
━━━━━▇▇▇━━━━━━━━▇▇▇━━━ ← 墙(投影轴)
↑ ↑
影子A 影子B
影子分离!找到分离轴,两物体不相交7.4.2 数学推导
投影轴的选择:
对于凸多边形,只需检测每条边的法线方向作为投影轴。
矩形有 4 条边,但只有 2 个不同的法线方向:
────────→ edge1 (法线: ↑)
┌─────────┐
│ │ ← edge2 (法线: →)
│ │
│ │ ← edge4 (法线: ←, 等价于 →)
│ │
└─────────┘
←──────── edge3 (法线: ↓, 等价于 ↑)
两个矩形最多需要检测 4 条轴(每个矩形贡献 2 条)投影计算:
点 P 在轴 A 上的投影长度(标量):
proj = P · A / |A|
其中:
- P · A 是点积
- |A| 是轴的长度
如果轴 A 是单位向量,则:
proj = P · A = Px * Ax + Py * Ay代码实现:
// 来源:utils/src/rect.ts - 简化版 SAT 实现
class Vector {
x: number;
y: number;
// 点积
dotProduct(v: Vector): number {
return this.x * v.x + this.y * v.y;
}
// 边向量
edge(v: Vector): Vector {
return new Vector(v.x - this.x, v.y - this.y);
}
// 法向量(垂直向量)
normal(): Vector {
// 2D 中,(x, y) 的法向量是 (-y, x) 或 (y, -x)
return new Vector(-this.y, this.x);
}
}
class Projection {
min: number;
max: number;
constructor(min: number, max: number) {
this.min = min;
this.max = max;
}
// 投影是否重叠
overlaps(p: Projection): boolean {
return this.max >= p.min && p.max >= this.min;
}
}7.4.3 代码实现
完整的 SAT 相交检测:
// 来源:utils/src/rect.ts
getRectIntersection(rectA: Rect, rectB: Rect) {
const pointsA = this.getRectPoints(rectA);
const pointsB = this.getRectPoints(rectB);
// 优化:无旋转矩形使用 AABB 检测
if (
(rectA.rotate === 0 || rectA.rotate === 360) &&
rectA.skewX === 0 && rectA.skewY === 0 &&
(rectB.rotate === 0 || rectB.rotate === 360) &&
rectB.skewX === 0 && rectB.skewY === 0
) {
return (
rectA.left < rectB.left + rectB.width &&
rectA.left + rectA.width > rectB.left &&
rectA.top < rectB.top + rectB.height &&
rectA.height + rectA.top > rectB.top
);
}
// 有旋转时使用 SAT
const polygonsCollide = (polygon1, polygon2) => {
// 获取所有候选分离轴
const axes = this.getAxes(polygon1).concat(this.getAxes(polygon2));
for (const axis of axes) {
// 计算两个多边形在当前轴上的投影
const projection1 = this.project(axis, polygon1);
const projection2 = this.project(axis, polygon2);
// 如果投影不重叠,找到分离轴,不相交
if (!projection1.overlaps(projection2)) {
return false;
}
}
// 所有轴上投影都重叠,相交
return true;
};
return polygonsCollide(pointsA, pointsB);
},
// 获取投影轴(边的法线)
getAxes(points: Point[]) {
const v1 = new Vector();
const v2 = new Vector();
const axes: Vector[] = [];
for (let i = 0, len = points.length - 1; i < len; i++) {
v1.x = points[i].x;
v1.y = points[i].y;
v2.x = points[i + 1].x;
v2.y = points[i + 1].y;
// 边向量的法线
axes.push(v1.edge(v2).normal());
}
// 最后一条边(最后一点到第一点)
v1.x = points.at(-1)!.x;
v1.y = points.at(-1)!.y;
v2.x = points[0].x;
v2.y = points[0].y;
axes.push(v1.edge(v2).normal());
return axes;
},
// 计算多边形在轴上的投影
project(axis: Vector, points: Point[]) {
const scalars: number[] = [];
const v = new Vector();
points.forEach((point) => {
v.x = point.x;
v.y = point.y;
// 点积得到投影标量
scalars.push(v.dotProduct(axis));
});
return new Projection(Math.min(...scalars), Math.max(...scalars));
}7.4.4 性能优化
优化策略:
SAT 性能优化层级:
Level 1: AABB 预检测(最快)
┌────────────────────────────────────────────┐
│ if (!aabb1.intersects(aabb2)) return false │
│ │
│ 复杂度: O(1) │
│ 作用: 快速剔除明显不相交的情况 │
└────────────────────────────────────────────┘
│
│ AABB 相交
▼
Level 2: 无旋转优化(快)
┌────────────────────────────────────────────┐
│ if (无旋转 && 无斜切) { │
│ return AABB 相交检测; │
│ } │
│ │
│ 复杂度: O(1) │
│ 作用: 对规整矩形避免 SAT 计算 │
└────────────────────────────────────────────┘
│
│ 有旋转或斜切
▼
Level 3: SAT 完整检测(慢)
┌────────────────────────────────────────────┐
│ for (axis of axes) { │
│ proj1 = project(axis, poly1); │
│ proj2 = project(axis, poly2); │
│ if (!overlaps) return false; │
│ } │
│ │
│ 复杂度: O(n) n = 边数 │
│ 两个矩形 = 8 次投影计算 │
└────────────────────────────────────────────┘实际应用中的优化:
// 来源:infinite-renderer/src/vms/base/base-element-vm.ts
hits(rect: Rectangle): boolean {
const bounds = this.view.getBounds();
// 优化 1: AABB 预检测
if (!bounds.intersects(rect)) {
return false;
}
// 优化 2: 有 mask 时才做精确检测
if (this.view.mask) {
// 精确检测...
}
// 优化 3: 有 hitArea 时才做 SAT
if (this.view.hitArea && this.view.hitArea instanceof Rectangle) {
return doesHitAreaIntersectRect(
this.view.hitArea,
rect,
this.view.worldTransform
);
}
return false;
}7.5 遮罩层处理
7.5.1 Cover Mask 机制
EventBoundary 支持添加"覆盖遮罩",遮罩区域会阻断点击事件:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
export class EventBoundary implements IEventBoundary {
private masks: Set<MaskTarget> = new Set();
// 添加覆盖遮罩
addCoverMask(mask: MaskTarget): void {
this.masks.add(mask);
}
// 移除覆盖遮罩
removeCoverMask(mask: MaskTarget): void {
this.masks.delete(mask);
}
// 检测点/矩形是否命中遮罩
private _hitMask<T extends IPointData | Rectangle>(point: T): boolean {
const isRect = point instanceof Rectangle;
for (const mask of this.masks) {
if (isRect) {
// 矩形检测
if (mask instanceof Graphics &&
mask.getBounds(true).intersects(point) &&
doesGraphicsIntersectRect(mask, point)) {
return true;
}
if (mask instanceof Sprite &&
mask.getBounds(true).intersects(point) &&
doesSpriteIntersectRect(mask, point)) {
return true;
}
} else {
// 点检测
if (mask.containsPoint(point)) {
return true;
}
}
}
return false;
}
}Cover Mask 应用场景:
场景:模态对话框遮罩
┌──────────────────────────────────────────┐
│ 画布 │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Elem A │ │ Elem B │ │ Elem C │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Cover Mask (半透明遮罩) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ Modal Dialog │ │ │
│ │ │ │ │ │
│ │ │ [OK] [Cancel] │ │ │
│ │ └─────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
│ │
└──────────────────────────────────────────┘
用户点击 Elem A 区域:
1. _hitMask() 检测到命中 Cover Mask
2. 返回 null,事件被遮罩阻断
3. Elem A 不会被选中7.5.2 元素 Mask 检测
元素自身的 mask 属性会影响点击和相交检测:
// 来源:infinite-renderer/src/vms/base/base-element-vm.ts
contains(point: IPointData): boolean {
// 检测元素的 mask
if (this.view.mask) {
const maskObject = (this.view.mask as MaskData)._isMaskData
? (this.view.mask as MaskData).maskObject
: (this.view.mask as Graphics);
if (maskObject instanceof Graphics) {
// Graphics mask 使用 containsPoint
return !!maskObject.containsPoint(point);
}
}
// ...
}Mask 类型:
┌─────────────────────────────────────────────────────────────┐
│ Mask 类型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Graphics Mask │
│ ┌─────────────────────────┐ │
│ │ ╭──────────────────╮ │ ← Graphics 定义的形状 │
│ │ │ 可见区域 │ │ │
│ │ │ │ │ │
│ │ ╰──────────────────╯ │ │
│ └─────────────────────────┘ │
│ 检测方法: containsPoint() │
│ │
│ 2. Sprite Mask │
│ ┌─────────────────────────┐ │
│ │ 🖼️ 图片纹理 │ ← 基于 alpha 通道 │
│ │ ▓▓▓▓▓▓▓▓ │ alpha > 0 的区域可见 │
│ │ ▓▓▓▓▓▓▓▓ │ │
│ │ ▓▓▓▓▓▓▓▓ │ │
│ └─────────────────────────┘ │
│ 检测方法: 像素级检测 │
│ │
│ 3. MaskData │
│ ┌─────────────────────────┐ │
│ │ 包装对象 │ │
│ │ - _isMaskData: true │ │
│ │ - maskObject: 实际 mask │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘7.5.3 像素级碰撞检测
对于 Sprite 类型的元素,支持基于像素 alpha 值的精确碰撞检测:
// 来源:infinite-renderer/src/common/hit-test.ts
const MAX_BUFFER_SIZE = 512;
export function doesSpritePixelsHitRect(sprite: Sprite, rect: Rectangle): boolean {
const { baseTexture, frame } = sprite.texture;
const { resolution } = baseTexture;
const source = baseTexture.getDrawableSource();
// 资源无效则不碰撞
if (!source || !baseTexture.valid) {
return false;
}
const center = tempPoint.set(
rect.x + rect.width / 2,
rect.y + rect.height / 2
);
let buffer: Uint8ClampedArray;
// Canvas 资源:直接读取像素
if ('getContext' in source) {
const ctx = source.getContext('2d', { willReadFrequently: true })!;
const localPoint = toLocal(sprite, center, tempPoint);
const localRect = tempRect.copyFrom(rect);
localRect.x = localPoint.x - rect.width / 2;
localRect.y = localPoint.y - rect.height / 2;
const imageData = ctx.getImageData(
Math.round((frame.x + localRect.x) * resolution),
Math.round((frame.y + localRect.y) * resolution),
Math.round(localRect.width * resolution),
Math.round(localRect.height * resolution),
);
buffer = imageData.data;
} else {
// 图片资源:缓存像素数据到 baseTexture
if (!(baseTexture as BufferedBaseTexture)._imageBufferData) {
// 限制缓存大小
const ratio = Math.min(
MAX_BUFFER_SIZE / baseTexture.realWidth,
MAX_BUFFER_SIZE / baseTexture.realHeight,
1,
);
const canvas = settings.ADAPTER.createCanvas(
baseTexture.realWidth * ratio,
baseTexture.realHeight * ratio,
);
const context = canvas.getContext('2d')!;
context.drawImage(source, 0, 0, canvas.width, canvas.height);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
// 释放临时 canvas
canvas.width = 1;
canvas.height = 1;
(baseTexture as BufferedBaseTexture)._imageBufferData = {
data: imageData,
ratio,
};
}
// 从缓存中裁剪区域
const { data, ratio = 1 } = (baseTexture as BufferedBaseTexture)._imageBufferData!;
// ... 坐标转换和裁剪
buffer = clip(data.data, data.width, data.height, ...);
}
// 检测 alpha 通道
const size = 4; // RGBA
for (let i = 0; i < buffer.length; i += size) {
const alpha = buffer[i + 3];
// 任何像素的 alpha 不为 0 则碰撞成功
if (alpha !== 0) {
return true;
}
}
return false;
}像素检测流程图:
像素级碰撞检测流程:
┌────────────────────────────────────────────────────────────┐
│ Sprite 图片 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ████████████ │ │
│ │ ██ ██ │ │
│ │ ██ ● 碰撞点 ██ │ │
│ │ ██ ██ │ │
│ │ ██ ██ │ │
│ │ ████████████ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
步骤:
1. 将碰撞区域从世界坐标转换到 Sprite 本地坐标
2. 读取该区域的像素数据(RGBA)
3. 遍历所有像素的 alpha 值
4. 如果任何 alpha > 0,则碰撞成功
透明区域 不透明区域
alpha = 0 alpha > 0
不碰撞 ✗ 碰撞 ✓性能优化:像素缓存:
// 缓存接口
export interface BufferedBaseTexture extends BaseTexture {
_imageBufferData?: {
data: ImageData;
ratio: number; // 缩放比例(限制最大尺寸)
};
}
// 缓存策略
// 1. 首次检测时创建缓存
// 2. 限制最大缓存尺寸为 512x512
// 3. 缓存存储在 baseTexture 上,随纹理生命周期7.6 Bypass 策略
7.6.1 策略类型
Bypass 枚举定义了四种元素检测行为:
// 来源:infinite-renderer/src/surfaces/event-boundary.ts
export enum Bypass {
None = 0, // 正常检测
Pass = 1, // 穿透当前元素
Skip = 2, // 跳过元素及子元素
Hits = 3, // 直接命中,不检测子元素
}策略行为对比:
┌─────────────────────────────────────────────────────────────────────┐
│ Bypass 策略行为 │
├─────────┬───────────────┬───────────────┬───────────────────────────┤
│ 策略 │ 检测自身 │ 检测子元素 │ 返回行为 │
├─────────┼───────────────┼───────────────┼───────────────────────────┤
│ None │ 是 │ 是 │ 子元素命中返回子元素, │
│ │ │ │ 否则返回自身 │
├─────────┼───────────────┼───────────────┼───────────────────────────┤
│ Pass │ 是 │ 是 │ 子元素命中返回子元素, │
│ │ (仅作范围过滤)│ │ 否则返回 null(穿透) │
├─────────┼───────────────┼───────────────┼───────────────────────────┤
│ Skip │ 否 │ 否 │ 直接返回 null │
├─────────┼───────────────┼───────────────┼───────────────────────────┤
│ Hits │ 是 │ 否 │ 命中自身立即返回 │
└─────────┴───────────────┴───────────────┴───────────────────────────┘7.6.2 应用场景
场景 1:None - 正常检测(默认)
Group 元素的默认行为:
┌───────────────────────────────┐
│ Group (Bypass.None) │
│ ┌─────────┐ ┌─────────┐ │
│ │ Child A │ │ Child B │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────┘
点击 Child A 区域:
1. 检测 Group → 命中
2. 递归检测子元素
3. 检测 Child A → 命中
4. 返回 Child A
点击 Group 空白区域:
1. 检测 Group → 命中
2. 递归检测子元素 → 无命中
3. 返回 Group场景 2:Pass - 透明容器
Layout 元素(画板):
┌───────────────────────────────┐
│ Layout (Bypass.Pass) │
│ ┌─────────┐ ┌─────────┐ │
│ │ Child A │ │ Child B │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────┘
点击 Child A 区域:
1. 检测 Layout → 命中(范围内)
2. 递归检测子元素
3. 检测 Child A → 命中
4. 返回 Child A
点击 Layout 空白区域:
1. 检测 Layout → 命中(范围内)
2. 递归检测子元素 → 无命中
3. Pass 策略 → 返回 null(穿透)
4. 可继续检测下层元素场景 3:Skip - 跳过锁定元素
锁定元素不参与点击检测:
┌───────────────────────────────┐
│ Element A (Bypass.Skip) │ ← 已锁定
│ ┌─────────┐ │
│ │ Child │ │
│ └─────────┘ │
└───────────────────────────────┘
┌───────────────────────────────┐
│ Element B (Bypass.None) │ ← 下层元素
└───────────────────────────────┘
点击 Element A 区域:
1. 检测 Element A → Skip
2. 跳过,不检测子元素
3. 继续检测 Element B场景 4:Hits - 快速命中
选中状态的元素快速响应:
┌───────────────────────────────┐
│ Element (Bypass.Hits) │ ← 已选中
│ ┌─────────┐ ┌─────────┐ │
│ │ Child A │ │ Child B │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────┘
点击任意位置(在 Element 范围内):
1. 检测 Element → 命中
2. Hits 策略 → 立即返回 Element
3. 不再检测子元素7.6.3 实现机制
TestFn 类型定义:
// 来源:infinite-renderer/src/types/event-boundary.ts
export type IBypass = 0 | 1 | 2 | 3;
export type TestFn<T = IBaseElementVm> = (element: T) => IBypass;在 Surface 中的使用:
// 来源:infinite-renderer/src/surfaces/surface.ts
hitTestElement(
rect: Rectangle,
test?: (element: BaseElementModel) => Bypass,
): BaseElementModel | null {
const element = this.eventBoundary.hitTest(
rect,
// 将 Model 层的 test 函数转换为 VM 层
test ? (element) => test(element.getModel()) : (element) => Bypass.None,
);
return element?.getModel() || null;
}典型 TestFn 实现:
// 示例:框选时的 test 函数
function createSelectionTest(
lockedElements: Set<string>,
selectedElements: Set<string>
): TestFn {
return (vm: IBaseElementVm): IBypass => {
const model = vm.getModel();
// 锁定元素跳过
if (lockedElements.has(model.uuid)) {
return Bypass.Skip;
}
// Layout 元素穿透
if (model.type === 'layout') {
return Bypass.Pass;
}
// 已选中元素快速命中
if (selectedElements.has(model.uuid)) {
return Bypass.Hits;
}
// 默认正常检测
return Bypass.None;
};
}7.7 本章小结
核心概念回顾
事件系统架构:
┌─────────────────────────────────────────────────────────────┐
│ EventBoundary │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ hitTest │ │ intersect │ │ masks │ │
│ │ 点击检测 │ │ 区域检测 │ │ 遮罩管理 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 递归遍历引擎 │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Bypass 策略控制 │ │ │
│ │ │ None / Pass / Skip / Hits │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 碰撞检测算法层 │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │ │
│ │ │ AABB │ │ OBB │ │ 精确检测 │ │ │
│ │ │ 包围盒 │ │ 有向盒 │ │ mask/hitArea │ │ │
│ │ └─────────┘ └─────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ SAT 分离轴算法 │ │ │
│ │ │ 用于旋转多边形的相交判定 │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ 像素级碰撞检测 │ │ │
│ │ │ 基于 alpha 通道的精确检测 │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘关键代码路径
| 功能模块 | 文件路径 |
|---|---|
| EventBoundary | infinite-renderer/src/surfaces/event-boundary.ts |
| 碰撞检测工具 | infinite-renderer/src/common/hit-test.ts |
| 元素检测方法 | infinite-renderer/src/vms/base/base-element-vm.ts |
| SAT 算法 | utils/src/rect.ts |
| 类型定义 | infinite-renderer/src/types/event-boundary.ts |
性能优化要点
- 分层检测:AABB → OBB → 精确检测,逐级精细化
- 早期退出:AABB 不相交时立即返回
- 像素缓存:Sprite 像素数据缓存到 baseTexture
- 遍历顺序:从后向前遍历,上层元素优先命中
下一章预告
第8章将讨论插件系统,包括:
- 插件注册与生命周期
- 核心插件实现
- 事件分发机制
- 自定义插件开发
