《游戏引擎实现原理》4.3 空间查询、分层与事件

边界上最后一块职责是回答引擎的提问:射线先打到谁、这个区域内有什么、A 和 B 是刚开始接触还是早就碰着。本节用位掩码做碰撞分层,实现 raycast 与 overlap,再把物理库给的接触列表差分成 enter / exit / stay 三态事件,全部可在 Node 直跑。

4.3 空间查询、分层与事件

边界上最后一块职责,是回答引擎的提问:从这一点射出去的射线先打到谁?这个区域内有哪些东西?A 和 B 是从这一帧开始接触,还是上一帧就在碰?物理库通常都提供 raycast / overlap,但「怎么用掩码过滤、事件怎么去重、顺序怎么定」全是引擎侧的事——把这些丢给物理库,等于把游戏规则塞进了第三方代码里。

这一节做三件事:用位掩码实现碰撞分层;实现 raycast 与 overlap 两个查询(先过滤、再几何判定);用一个接触对集合的差分,把物理库给的「当前接触列表」翻译成 enter / exit / stay 三态事件。

分层:位掩码,而不是字符串名字

分层不是为了「物理上能不能碰」——那是求解器的事;分层是为了在引擎侧少算。用位序号给每个层编号,一个物体带上两组掩码:它属于哪些层,它关心哪些层。两个物体能否交互,看的是「对方的归属层」与「我的过滤层」有没有交集。

这里刻意不用 enum(erasable syntax 禁止),而用 as const 对象:位序号用 1 << n 展开成掩码,最多 32 层。

// file: src/subsys/physics/collision-layers.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/physics.test.ts
//
// helio 子系统:碰撞分层与掩码。
// 每个层是一个位序号 0..31;一个物体持有「自己属于哪些层」与「能和哪些层碰」两组位。

/** 预定义层:这里是位序号,不是位掩码本身。 */
export const LAYER = {
  Default: 0,
  Player: 1,
  Enemy: 2,
  Terrain: 3,
  Trigger: 4,
} as const;

/** 位掩码:把若干层序号并成一个 32 位整数。 */
export function maskOf(...layers: number[]): number {
  let m = 0;
  for (const l of layers) m |= 1 << l;
  return m;
}

/**
 * 两组掩码只要有一位重合就能碰。
 * 典型调用:`canCollide(otherBelongsTo, myCollidesWith)`——对方的归属层与我的过滤层求交。
 */
export function canCollide(a: number, b: number): boolean {
  return (a & b) !== 0;
}

export function hasLayer(mask: number, layer: number): boolean {
  return (mask & (1 << layer)) !== 0;
}

可运行产物:raycast 与 overlap

有了掩码,查询就分两步:先按掩码过滤掉不相碰的层,再做几何判定。raycast 用 slab 法求射线与 AABB 的交点距离,返回最近命中;overlap 直接做盒重叠测试,返回全部 id。两者都把「过滤」放在最前面,避免为不相碰的层白算。

// file: src/subsys/physics/spatial-query.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/physics.test.ts
//
// helio 子系统:空间查询。raycast 用于拾取 / 射线检测,overlap 用于触发器。
// 两者都先按掩码过滤,再做几何判定,避免为不相碰的层白算。

import type { Vec3 } from '../../core/vec3.ts';
import type { Aabb } from './swept-aabb.ts';
import { canCollide } from './collision-layers.ts';

export interface SpatialItem {
  readonly id: number;
  readonly box: Aabb;
  readonly mask: number;
}

export interface RayHit {
  readonly id: number;
  /** 命中距离(世界单位)。 */
  readonly t: number;
}

/** 射线与 AABB 求交(slab 法),命中返回进入距离。 */
function rayAabb(origin: Vec3, dir: Vec3, box: Aabb): number | null {
  let tMin = 0;
  let tMax = Number.POSITIVE_INFINITY;
  for (let i = 0; i < 3; i++) {
    const o = origin[i]!;
    const d = dir[i]!;
    if (Math.abs(d) < 1e-9) {
      if (o < box.min[i]! || o > box.max[i]!) return null;
      continue;
    }
    const inv = 1 / d;
    let t1 = (box.min[i]! - o) * inv;
    let t2 = (box.max[i]! - o) * inv;
    if (t1 > t2) {
      const tmp = t1;
      t1 = t2;
      t2 = tmp;
    }
    if (t1 > tMin) tMin = t1;
    if (t2 < tMax) tMax = t2;
    if (tMin > tMax) return null;
  }
  return tMin;
}

/** 返回最近的命中(按距离)。queryMask 为 0 表示不过滤。 */
export function raycast(
  origin: Vec3,
  dir: Vec3,
  maxDist: number,
  items: readonly SpatialItem[],
  queryMask = 0,
): RayHit | null {
  let best: RayHit | null = null;
  for (const it of items) {
    if (queryMask !== 0 && !canCollide(queryMask, it.mask)) continue;
    const t = rayAabb(origin, dir, it.box);
    if (t === null || t > maxDist) continue;
    if (best === null || t < best.t) best = { id: it.id, t };
  }
  return best;
}

/** 返回与 query 盒重叠的全部 id。 */
export function overlap(query: Aabb, items: readonly SpatialItem[], queryMask = 0): number[] {
  const out: number[] = [];
  for (const it of items) {
    if (queryMask !== 0 && !canCollide(queryMask, it.mask)) continue;
    if (aabbOverlap(query, it.box)) out.push(it.id);
  }
  return out;
}

function aabbOverlap(a: Aabb, b: Aabb): boolean {
  return (
    a.min[0] <= b.max[0] &&
    a.max[0] >= b.min[0] &&
    a.min[1] <= b.max[1] &&
    a.max[1] >= b.min[1] &&
    a.min[2] <= b.max[2] &&
    a.max[2] >= b.min[2]
  );
}

接触事件:从「谁在碰」到 enter / exit / stay

物理库每步只告诉你「当前哪些对在接触」,但游戏逻辑要的是变化:这一帧刚开始碰(enter)、一直碰着(stay)、这一帧分开了(exit)。用上一帧的接触对集合做差集就能得到三态。关键细节是「无向对」——(1,2) 与 (2,1) 必须视为同一对,否则 enter / exit 会重复触发。

// file: src/subsys/physics/collision-events.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/physics.test.ts
//
// helio 子系统:接触事件去重与分发。
// 物理库每步只告诉你「当前哪些对在接触」;引擎要的是 enter / exit / stay 三态。
// 用上一帧的接触对集合做差分,输出顺序稳定(先 enter,后 exit)。

export type CollisionPhase = 'enter' | 'exit' | 'stay';

export interface CollisionEvent {
  readonly a: number;
  readonly b: number;
  readonly phase: CollisionPhase;
}

/** 无向对:小的在前,保证 (1,2) 与 (2,1) 视为同一对。 */
function pairKey(a: number, b: number): string {
  return a < b ? `${a}:${b}` : `${b}:${a}`;
}

function split(key: string): [number, number] {
  const i = key.indexOf(':');
  return [Number(key.slice(0, i)), Number(key.slice(i + 1))];
}

export class ContactTracker {
  #prev = new Set<string>();

  /** 喂入本帧接触对,返回本帧事件。 */
  update(pairs: readonly (readonly [number, number])[]): CollisionEvent[] {
    const curr = new Set<string>();
    for (const [a, b] of pairs) curr.add(pairKey(a, b));
    const out: CollisionEvent[] = [];
    for (const key of curr) {
      const [a, b] = split(key);
      out.push({ a, b, phase: this.#prev.has(key) ? 'stay' : 'enter' });
    }
    for (const key of this.#prev) {
      if (curr.has(key)) continue;
      const [a, b] = split(key);
      out.push({ a, b, phase: 'exit' });
    }
    this.#prev = curr;
    return out;
  }

  get activePairs(): number {
    return this.#prev.size;
  }
}

本机验证

把这三块一起测:掩码过滤、raycast 取最近、overlap 取全部、事件三态。

// file: src/subsys/physics/physics.test.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/physics.test.ts
//
// 断言:分层掩码过滤、raycast / overlap 空间查询、接触事件 enter / exit / stay。

import { describe, expect, it } from 'vitest';
import { LAYER, canCollide, hasLayer, maskOf } from './collision-layers.ts';
import { raycast, overlap, type SpatialItem } from './spatial-query.ts';
import { ContactTracker } from './collision-events.ts';
import { aabbFromCenter } from './swept-aabb.ts';

const PLAYER = maskOf(LAYER.Player);
const ENEMY = maskOf(LAYER.Enemy);
const TERRAIN = maskOf(LAYER.Terrain);

describe('分层掩码', () => {
  it('玩家只关心敌人,不碰地形', () => {
    const playerFilter = maskOf(LAYER.Enemy); // 玩家:只和敌人碰
    const enemyBelongs = maskOf(LAYER.Enemy); // 敌人:属于 Enemy 层
    const terrainBelongs = maskOf(LAYER.Terrain); // 地形:属于 Terrain 层
    expect(canCollide(enemyBelongs, playerFilter)).toBe(true);
    expect(canCollide(terrainBelongs, playerFilter)).toBe(false);
    expect(hasLayer(maskOf(LAYER.Player, LAYER.Trigger), LAYER.Trigger)).toBe(true);
  });
});

const items: SpatialItem[] = [
  { id: 1, box: aabbFromCenter([5, 0, 0], [0.5, 0.5, 0.5]), mask: PLAYER },
  { id: 2, box: aabbFromCenter([9, 0, 0], [0.5, 0.5, 0.5]), mask: ENEMY },
  { id: 3, box: aabbFromCenter([12, 0, 0], [0.5, 0.5, 0.5]), mask: TERRAIN },
];

describe('空间查询', () => {
  it('raycast 返回最近的命中', () => {
    const hit = raycast([0, 0, 0], [1, 0, 0], 100, items, 0);
    expect(hit?.id).toBe(1);
    expect(hit?.t).toBeCloseTo(4.5, 6);
  });

  it('掩码过滤掉不相碰的层', () => {
    expect(raycast([0, 0, 0], [1, 0, 0], 100, items, TERRAIN)?.id).toBe(3);
    expect(raycast([0, 0, 0], [1, 0, 0], 100, items, maskOf(LAYER.Trigger))).toBeNull();
  });

  it('overlap 返回全部重叠 id', () => {
    const q = aabbFromCenter([6, 0, 0], [3, 3, 3]);
    expect(overlap(q, items, 0).sort((x, y) => x - y)).toEqual([1, 2]);
  });
});

describe('接触事件', () => {
  it('差分出 enter / stay / exit', () => {
    const t = new ContactTracker();
    expect(t.update([[1, 2]]).map((e) => e.phase)).toEqual(['enter']);
    expect(t.update([[2, 1]]).map((e) => e.phase)).toEqual(['stay']);
    const e3 = t.update([[1, 3]]);
    expect(e3.find((e) => e.phase === 'exit')?.b).toBe(2);
    expect(e3.find((e) => e.phase === 'enter')?.b).toBe(3);
    expect(t.activePairs).toBe(1);
  });
});

本机实跑(npx vitest run src/subsys/physics/physics.test.ts):

 ✓ src/subsys/physics/physics.test.ts > 分层掩码 > 玩家只关心敌人,不碰地形 1ms
 ✓ src/subsys/physics/physics.test.ts > 空间查询 > raycast 返回最近的命中 0ms
 ✓ src/subsys/physics/physics.test.ts > 空间查询 > 掩码过滤掉不相碰的层 0ms
 ✓ src/subsys/physics/physics.test.ts > 空间查询 > overlap 返回全部重叠 id 0ms
 ✓ src/subsys/physics/physics.test.ts > 接触事件 > 差分出 enter / stay / exit 0ms
 Test Files  1 passed (1)
      Tests  5 passed (5)

到这里,第四章的三节就把「引擎与物理库的边界」补齐了:4.1 划接口与同步,4.2 管固定步长、插值与 CCD,4.3 管查询、分层与事件。整个过程中我们没有写一行求解器——这正是这条边界存在的意义。下一章离开物理,去做让《微光岛》有声音的音频子系统。

阅读导航:上一节:4.2 固定步长、插值与穿透 · 下一节:5.1 混音图与音频总线 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径