7.2 轻量碰撞检测与空间划分

动手写 t1/t2 两级里最核心的碰撞内核:AABB 与圆相交(整数平方距离比较,无开方)、按网格做的 3x3 邻域 broad phase,并用可播种 LCG 撒点,对照暴力 O(n^2) 与网格的候选对数随实体数增长,产 CSV 与 SVG。

站内《游戏引擎实现原理》四卷 180 篇 是从零实现引擎内核(含 ECS 与系统调度),那里会讲一套通用的碰撞管线怎么搭;本章讲的是小游戏语境下到底需要多少物理:分级取舍、轻量碰撞与空间划分、手感参数。差别在于,那边把碰撞做成框架,这边只做《合成小岛》真正会用到的那几十行。

上一节把物理需求分了五级,并说明《合成小岛》落在 t1(矩形相交)、将来可能补到 t2(圆 + 网格)。这一节就动手把 t1 和 t2 里最核心的部分写出来:怎么判定两个物体相撞,以及物体多起来时怎么不算那么多次。

轻量碰撞:只做需要的那一层

碰撞检测在工程上分两段:broad phase(粗筛,快速排除不可能相撞的对)和 narrow phase(细判,对候选对做精确相交测试)。t1 的玩法实体少,broad phase 直接暴力两两比就行;t2 的实体可能上千,暴力就成了 O(n²)。这一节把两段都写出来,并让 broad phase 在暴力和网格之间可切换。

narrow phase 只有两个原语:AABB 相交(矩形,四边分离即不相交)和圆相交(比较平方距离,避免开方)。小游戏里 99% 的判定都能归到这两个原语上。

P 层:AABB / 圆 / 网格

下面这段是 P 层(内核层)产物:core/src/e07/collision.ts。它是纯函数式的碰撞内核——输入几何体,输出是否相交或相交对列表。全整数坐标、无 Math.random()、无开方,因此既能被单元测试直接跑,也能被浏览器原型内联。

// file: core/src/e07/collision.ts
// tier: P
// verify: node --test 'core/test/e07_*.test.ts'
//
// 《合成小岛》轻量碰撞内核:AABB 相交、圆相交(平方距离比较,无开方)、
// 以及按网格做的空间划分 broad phase。全整数坐标、无 Math.random()、只用可擦除语法。
// 正确性前提:cellPx >= 2 * maxRadiusPx 时,只查 3x3 邻域即可判定圆相交。

export interface Vec { x: number; y: number }
export interface Aabb { x: number; y: number; w: number; h: number }
export interface Circle { x: number; y: number; r: number }
export interface Body { id: number; x: number; y: number; r: number }
export interface GridConfig { cellPx: number; cols: number; rows: number }

function fclamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; }

/** AABB 相交:四边分离即不相交(严格不等号,贴边不算相交)。 */
export function aabbOverlap(a: Aabb, b: Aabb): boolean {
  return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}

/** 圆相交:比较平方距离,避免开方;贴边(距离 == 半径和)算相交。 */
export function circleOverlap(a: Circle, b: Circle): boolean {
  const dx = a.x - b.x, dy = a.y - b.y, rr = a.r + b.r;
  return dx * dx + dy * dy <= rr * rr;
}

/** 矩形与圆相交:取矩形上离圆心最近的点,再比平方距离。 */
export function aabbCircleOverlap(a: Aabb, c: Circle): boolean {
  const nx = fclamp(c.x, a.x, a.x + a.w);
  const ny = fclamp(c.y, a.y, a.y + a.h);
  const dx = c.x - nx, dy = c.y - ny;
  return dx * dx + dy * dy <= c.r * c.r;
}

/** 由世界尺寸与网格边长推导网格维度(向上取整,整数运算)。 */
export function gridOf(worldPx: number, cellPx: number): GridConfig {
  const n = (worldPx + cellPx - 1) / cellPx;
  const cols = Math.floor(n);
  return { cellPx, cols, rows: cols };
}

/** 坐标 -> 单元下标(clamp 到网格内,越界坐标也落进边缘单元)。 */
export function cellIndex(g: GridConfig, x: number, y: number): number {
  const col = fclamp(Math.floor(x / g.cellPx), 0, g.cols - 1);
  const row = fclamp(Math.floor(y / g.cellPx), 0, g.rows - 1);
  return row * g.cols + col;
}

/** 某个坐标点所在单元的 3x3 邻域单元下标(去掉越界部分)。 */
export function neighborCells(g: GridConfig, x: number, y: number): number[] {
  const col = fclamp(Math.floor(x / g.cellPx), 0, g.cols - 1);
  const row = fclamp(Math.floor(y / g.cellPx), 0, g.rows - 1);
  const out: number[] = [];
  for (let dr = -1; dr <= 1; dr += 1) {
    for (let dc = -1; dc <= 1; dc += 1) {
      const r = row + dr, c = col + dc;
      if (r >= 0 && r < g.rows && c >= 0 && c < g.cols) out.push(r * g.cols + c);
    }
  }
  return out;
}

/** 把实体按中心点分桶到网格:返回每个单元里的实体下标列表。 */
export function buildGrid(g: GridConfig, bodies: Body[]): number[][] {
  const cells: number[][] = [];
  for (let i = 0; i < g.cols * g.rows; i += 1) cells.push([]);
  for (let i = 0; i < bodies.length; i += 1) cells[cellIndex(g, bodies[i].x, bodies[i].y)].push(i);
  return cells;
}

/** 暴力求全部真实相交对(返回排序后的 "i,j" 列表,用于与网格结果对照)。 */
export function bruteOverlapPairs(bodies: Body[]): string[] {
  const out: string[] = [];
  for (let i = 0; i < bodies.length; i += 1) {
    for (let j = i + 1; j < bodies.length; j += 1) {
      if (circleOverlap(bodies[i], bodies[j])) out.push(i + "," + j);
    }
  }
  return out;
}

/** 网格求全部真实相交对:broad phase 只给候选,narrow phase 再判圆相交。 */
export function gridOverlapPairs(g: GridConfig, bodies: Body[]): string[] {
  const cells = buildGrid(g, bodies);
  const out: string[] = [];
  for (let i = 0; i < bodies.length; i += 1) {
    const a = bodies[i];
    for (const ci of neighborCells(g, a.x, a.y)) {
      for (const j of cells[ci]) {
        if (j > i && circleOverlap(a, bodies[j])) out.push(i + "," + j);
      }
    }
  }
  out.sort((p, q) => {
    const pi = Number(p.split(",")[0]), pj = Number(p.split(",")[1]);
    const qi = Number(q.split(",")[0]), qj = Number(q.split(",")[1]);
    return pi - qi || pj - qj;
  });
  return out;
}

圆相交那一行是这套内核的关键:dx * dx + dy * dy <= rr * rr。它比较的是平方距离,从头到尾没有 Math.sqrt。对「是否相交」这种布尔判定来说,开方是纯浪费——a² + b² <= r² 与 sqrt(a² + b²) <= r 等价,但前者全是整数乘法,跨平台结果完全一致(浮点开方在极端值上会有误差)。这是小游戏碰撞代码的第一条纪律:能比平方就别开方。gridOf 里的 (worldPx + cellPx - 1) / cellPx 是整数上取整(ceil 的整数写法),避免浮点;neighborCells 返回 3x3 邻域,角单元只有 4 个、内部单元 9 个,这是空间划分的边界处理,也是最容易漏掉的地方。

P 层测试:网格必须与暴力结果一致

空间划分最怕「漏判」:网格把两个真相撞的物体分到不相邻的单元,就漏掉了。所以测试的核心不是「网格更快」,而是**「网格和暴力求出的相交对完全一致」**。P 层测试 core/test/e07_collision.test.ts 用可播种 LCG 撒点,让两种方法对拍。跑它,输出是这样(同机相对量级,耗时不可移植):

✔ AABB:相交与分离
✔ 圆:贴边算相交(平方距离比较,无开方)
✔ 矩形与圆:最近点法
✔ 网格维度与单元下标(越界坐标 clamp 到边缘单元)
✔ 分桶:每个实体恰好落进一个单元
✔ 网格与暴力求得完全相同的相交对(正确性)
✔ 网格候选对数远小于暴力(同机相对量级)
✔ 同一输入两遍结果一致(无隐藏随机)
ℹ tests 8
ℹ pass 8
ℹ fail 0

第六行是这套实现的「安全网」:600 个随机圆里,网格与暴力求出的相交对逐条相等,证明在 cellPx >= 2 * radiusPx 的前提下 3x3 邻域不会漏判。测试里的点集用可播种 LCG而不是 Math.random() 生成——这样每次跑的点集完全一样,断言才能稳定,也符合 core/ 的确定性要求。

S 层:候选对数随实体数增长

P 层证明了「网格判得对」,但没量化「网格省多少」。下面这段是 S 层(仿真层)脚本:它在同样的世界里撒不同数量的圆,统计暴力与网格两种 broad phase 的候选对数(也就是检测次数),并核对两种方法的真实相交对完全一致。它读的配置 config/e07_collision.json 是这样:

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "碰撞检测复杂度对照参数:世界尺寸 / 网格边长 / 实体半径全为像素整数。cellPx >= 2*radiusPx 是「只查 3x3 邻域即可判定圆相交」的正确性前提。",
  "worldPx": 960,
  "cellPx": 48,
  "radiusPx": 24,
  "counts": [100, 200, 400, 800, 1600, 3200],
  "rounds": 8
}
#!/usr/bin/env python3
# file: sim/e07_collision_bench.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e07_collision_bench.py --config config/ --out out/
"""碰撞 broad phase 复杂度对照:暴力 O(n^2) vs 网格 3x3 邻域,全整数、纯 Python SVG。
产出 out/e07_collision_bench.json + _bench.csv + _bench.svg。"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def make_bodies(n, world, r, seed):
    """可播种 LCG(Numerical Recipes),与 core 测试同口径。"""
    s, span, out = seed & 0xFFFFFFFF, world - 2 * r, []
    for _ in range(n):
        pts = []
        for _ in range(2):
            s = (1664525 * s + 1013904223) & 0xFFFFFFFF
            pts.append(r + s % span)
        out.append((pts[0], pts[1], r))
    return out


def buckets(bodies, cell, cols):
    cs = [[] for _ in range(cols * cols)]
    for i, b in enumerate(bodies):
        cs[min(b[1] // cell, cols - 1) * cols + min(b[0] // cell, cols - 1)].append(i)
    return cs


def near(x, y, cell, cols):
    """坐标点所在单元的 3x3 邻域下标(去掉越界部分)。"""
    c, r = min(x // cell, cols - 1), min(y // cell, cols - 1)
    return [rr * cols + cc for rr in range(max(0, r - 1), min(cols, r + 2))
            for cc in range(max(0, c - 1), min(cols, c + 2))]


def hit(a, b):
    dx, dy, rr = a[0] - b[0], a[1] - b[1], a[2] + b[2]
    return dx * dx + dy * dy <= rr * rr


def bench(n, world, r, cell, cols, seed):
    bodies = make_bodies(n, world, r, seed)
    cs = buckets(bodies, cell, cols)
    cand, ghits = 0, []
    for i, a in enumerate(bodies):
        for ci in near(a[0], a[1], cell, cols):
            for j in cs[ci]:
                if j > i:
                    cand += 1
                    if hit(a, bodies[j]):
                        ghits.append((i, j))
    bhits = sorted((i, j) for i in range(n) for j in range(i + 1, n) if hit(bodies[i], bodies[j]))
    assert sorted(ghits) == bhits, f"网格与暴力相交对不一致 n={n}"
    return n * (n - 1) // 2, cand, len(bhits)


def draw_svg(path, rows):
    """横轴实体数,两条折线分别为暴力与网格候选对数。"""
    W, H, L, T, PW, PH = 760, 400, 70, 50, 660, 300
    mx = max(r["brutePairs"] for r in rows) or 1
    step = PW // max(1, len(rows) - 1)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
                             "width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    for key, color, label, tx in (("brutePairs", "#dc2626", "暴力 C(n,2)", L),
                                  ("gridPairs", "#2563eb", "网格 3x3", L + 140)):
        pts = " ".join(f"{L + i * step},{T + PH - PH * r[key] // mx}" for i, r in enumerate(rows))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color,
                                        "stroke-width": "2"})
        ET.SubElement(svg, "text", {"x": str(tx), "y": str(T - 8), "font-size": "12",
                                    "fill": color}).text = label
    for i, r in enumerate(rows):
        ET.SubElement(svg, "text", {"x": str(L + i * step - 12), "y": str(T + PH + 20),
                                    "font-size": "11", "fill": "#6b7280"}).text = str(r["n"])
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = json.load(open(os.path.join(args.config, "e07_collision.json"), encoding="utf-8"))
    world, cell, r, seed = cfg["worldPx"], cfg["cellPx"], cfg["radiusPx"], cfg["seed"]
    cols = (world + cell - 1) // cell
    assert cell >= 2 * r, "cellPx 必须 >= 2*radiusPx,否则 3x3 邻域不足以判定相交"
    rows = []
    for n in cfg["counts"]:
        bp, gp, hits = bench(n, world, r, cell, cols, seed)
        rows.append({"n": n, "brutePairs": bp, "gridPairs": gp, "hits": hits, "ratioPct": gp * 100 // bp})
    report = {"version": cfg["version"], "game": cfg["game"], "seed": seed, "worldPx": world,
              "cellPx": cell, "radiusPx": r, "cols": cols,
              "note": "同机相对量级,绝对数值不可移植;候选对数为 broad phase 检测次数。", "rows": rows}
    os.makedirs(args.out, exist_ok=True)
    report["svg"] = os.path.join(args.out, "e07_collision_bench.svg")
    report["svgBytes"] = draw_svg(report["svg"], rows)
    with open(os.path.join(args.out, "e07_collision_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e07_collision_bench.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["n", "brutePairs", "gridPairs", "hits", "gridPctOfBrute"])
        for r in rows:
            w.writerow([r["n"], r["brutePairs"], r["gridPairs"], r["hits"], r["ratioPct"]])
    last = rows[-1]
    print(json.dumps({"maxN": last["n"], "brutePairs": last["brutePairs"], "gridPairs": last["gridPairs"],
                      "gridPctOfBrute": last["ratioPct"], "hitsAtMaxN": last["hits"]}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样(同机相对量级,绝对数值不可移植):

{"maxN": 3200, "brutePairs": 5118400, "gridPairs": 117651, "gridPctOfBrute": 2, "hitsAtMaxN": 43592}

落盘的 out/e07_collision_bench.csv 把每个实体数下的候选对数记下来:

n,brutePairs,gridPairs,hits,gridPctOfBrute
100,4950,114,42,2
200,19900,473,181,2
400,79800,1774,682,2
800,319600,7279,2793,2
1600,1279200,29301,10924,2
3200,5118400,117651,43592,2

gridPctOfBrute 一列全是 2——网格的候选对数始终是暴力的约 2%(同机相对量级)。看绝对值更直观:1600 个圆时,暴力要检测 1279200 对,网格只要 29301 对。而且这个比例不随实体数增长:n 从 100 增到 3200,比例稳定在 2%。这说明网格把复杂度从 O(n²) 拉到了近似 O(n)——hits 一列也在增长(真实相交对从 42 涨到 43592),说明点撒得越密相交确实越多,网格并没有「漏掉」这些增长。

网格的两条正确性前提

网格能省检测次数,但它有前提,用错前提就会漏判。脚本里那行 assert cell >= 2 * r 就是前提之一。

第一,cellPx >= 2 * radiusPx。 两个半径 r 的圆,圆心距离必须 <= 2r 才可能相交。若单元边长 cellPx >= 2r,那么「距离 <= 2r」的两个圆心,在网格上的列差与行差都 <= 1——一定落在 3x3 邻域内。反之若 cellPx < 2r,两个相交的圆可能相隔两个单元,3x3 就漏了。配置里 cellPx = 48 = 2 * 24 正好卡在边界上。

第二,只对中心点分桶,且实体大小均匀。 这套实现把每个圆的中心点扔进一个单元,靠的是「所有圆半径相同」这个假设。若实体大小差异很大(一颗小弹珠和一面墙),大实体会跨很多单元,中心点分桶就会漏判,需要改用「实体包围盒覆盖的所有单元」来分桶。小游戏里实体尺寸接近时,中心点分桶性价比最高;尺寸差异大,就要退回包围盒覆盖法。

这两条前提也是「轻量碰撞」的代价:它用假设换性能。假设成立时它又快又对,假设不成立时它会静默漏判——所以第 15 章的端到端回放测试会专门用「网格 vs 暴力对拍」当作回归网。

小结与下一步

这一节把 t1/t2 的碰撞内核写完了:AABB 与圆相交(平方距离、无开方)、按中心点分桶的均匀网格 broad phase、3x3 邻域候选筛选。P 层测试证明网格与暴力求出的相交对完全一致;S 层仿真显示网格候选对数稳定在暴力的约 2%,把复杂度从 O(n²) 拉到近似 O(n)。

碰撞解决的是「碰到了没有」,但小游戏的手感还有另一半:碰到之后怎么动——落地弹多高、速度衰减多快、什么时候停下。下一节就来做这件事:物理手感调参。

阅读导航:上一节:小游戏的物理需求分级 · 下一节:物理手感调参 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制