Skip to content

第7章:事件系统与碰撞检测

本章概要

核心问题:如何精确判断用户点击了哪个元素?

在图形编辑器中,当用户点击画布时,系统需要快速准确地判断点击位置对应哪个图形元素。这涉及到两个核心技术:

  1. 事件边界(EventBoundary):管理整个碰撞检测系统,协调元素遍历和检测策略
  2. 碰撞检测算法:实际判断点/矩形与元素是否相交的数学方法

本章将深入分析这两大系统的设计与实现。


目录


7.1 EventBoundary 事件边界

7.1.1 核心职责

EventBoundary 是无限画布中事件系统的核心组件,负责:

┌─────────────────────────────────────────────────────────────┐
│                    EventBoundary 职责                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 点击检测(hitTest)                                       │
│     ├── 判断点击位置命中哪个元素                              │
│     └── 支持多种检测精度(元素/包围盒/有向包围盒)             │
│                                                              │
│  2. 区域检测(intersect)                                     │
│     ├── 查找与矩形区域相交的所有元素                          │
│     └── 用于框选、碰撞判断等场景                              │
│                                                              │
│  3. 遮罩管理(masks)                                         │
│     ├── 管理覆盖遮罩集合                                      │
│     └── 遮罩区域阻断点击事件穿透                              │
│                                                              │
│  4. 遍历控制(Bypass)                                        │
│     ├── 控制元素的检测行为                                    │
│     └── 支持 Skip/Pass/Hits/None 策略                        │
│                                                              │
└─────────────────────────────────────────────────────────────┘

7.1.2 架构设计

EventBoundary 的类结构:

typescript
// 来源: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;
}

设计要点

  1. 依赖 Viewport:通过 viewport 获取页面的元素树(viewport.page.children
  2. 遮罩集合:使用 Set 存储,支持动态添加/移除
  3. 泛型检测函数TestFn 允许外部控制每个元素的检测行为

7.1.3 递归遍历策略

EventBoundary 采用深度优先、从后向前的递归遍历策略:

typescript
// 来源: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 元素级点击检测

最精确的检测方式,考虑元素的 hitAreamask

typescript
// 来源: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 方法实现:

typescript
// 来源: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  false

7.2.3 包围盒检测

使用 AABB(Axis-Aligned Bounding Box)进行快速检测:

typescript
// 来源: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 相交判定

typescript
// 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)处理旋转元素:

typescript
// 来源: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 面积: 精确
检测速度: 快                      检测速度: 稍慢
误判率: 高                        误判率: 低

点在多边形内判定(射线法)

typescript
// 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 相交判定策略

与点击检测类似,区域检测也提供三种精度:

typescript
// 来源: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 方法:

typescript
// 来源: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 单次检测与批量检测

系统提供两种区域检测模式:

typescript
// 来源: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;
}

递归相交检测

typescript
// 来源: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 对比

特性hitTestintersect
返回值单个元素或 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

代码实现

typescript
// 来源: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 相交检测:

typescript
// 来源: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 次投影计算                      │
└────────────────────────────────────────────┘

实际应用中的优化

typescript
// 来源: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 支持添加"覆盖遮罩",遮罩区域会阻断点击事件:

typescript
// 来源: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 属性会影响点击和相交检测:

typescript
// 来源: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 值的精确碰撞检测:

typescript
// 来源: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
   不碰撞 ✗               碰撞 ✓

性能优化:像素缓存

typescript
// 缓存接口
export interface BufferedBaseTexture extends BaseTexture {
    _imageBufferData?: {
        data: ImageData;
        ratio: number;  // 缩放比例(限制最大尺寸)
    };
}

// 缓存策略
// 1. 首次检测时创建缓存
// 2. 限制最大缓存尺寸为 512x512
// 3. 缓存存储在 baseTexture 上,随纹理生命周期

7.6 Bypass 策略

7.6.1 策略类型

Bypass 枚举定义了四种元素检测行为:

typescript
// 来源: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 类型定义:

typescript
// 来源:infinite-renderer/src/types/event-boundary.ts

export type IBypass = 0 | 1 | 2 | 3;

export type TestFn<T = IBaseElementVm> = (element: T) => IBypass;

在 Surface 中的使用

typescript
// 来源: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 实现

typescript
// 示例:框选时的 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 通道的精确检测          │        │       │
│  │   └─────────────────────────────────────┘        │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

关键代码路径

功能模块文件路径
EventBoundaryinfinite-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

性能优化要点

  1. 分层检测:AABB → OBB → 精确检测,逐级精细化
  2. 早期退出:AABB 不相交时立即返回
  3. 像素缓存:Sprite 像素数据缓存到 baseTexture
  4. 遍历顺序:从后向前遍历,上层元素优先命中

下一章预告

第8章将讨论插件系统,包括:

  • 插件注册与生命周期
  • 核心插件实现
  • 事件分发机制
  • 自定义插件开发

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