4.1 九宫格 AOI 从零实现
服务器每帧都要把「谁在谁附近」算清楚,才能决定把哪条移动通知发给谁。如果每个玩家都能看到全服所有人,一次主城集结就会让出口带宽直接爆掉——所以 MMO 服务端必须有一套兴趣管理(AOI,Area of Interest)机制,把「全服广播」缩成「只发给视野内的人」。
AOI 的难点不在概念,而在两条硬指标:查询要快(每帧每人一次),移动要便宜(每帧每人至少一次),而且两件事必须在同一个数据结构上同时便宜。暴力扫描是 O(N),N 上千就撑不住;纯距离树(KD 树、四叉树)查询是对数级,但插入删除有常数开销,写多读多时并不划算。本节从零实现最经典的一种:九宫格。世界按固定格边长切成方格,每个格维护一份实体集合;玩家只关心自己所在格与相邻八格。它查询是常数级、移动是常数级、实现只有百来行,是绝大多数 MMO 的默认选择。
本章与站内既有内容的分工
第 4 章讲 AOI,站内已经有两篇架构文章讲过「大世界 AOI 该怎么做」和「状态同步如何省带宽」。本节不重复那些方案层面的取舍,只把九宫格从零写出来、跑通,并量出它的真实代价。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/aoi-system-design-for-large-game-worlds/ | 大世界的 AOI 架构方案与结构选型 | 从零实现九宫格,跑通查询与进出事件 |
/how-state-synchronization-games-reduce-bandwidth-pressure/ | 状态同步如何降低带宽压力 | 把 AOI 集合大小换算成每帧消息数,量化带宽 |
一句话:别人讲「AOI 该选哪种方案」,本节讲「九宫格这一种怎么写、怎么证明它对」。
世界切格:把连续坐标离散化
先说共享类型。卷二所有玩法系统都用同一套坐标与实体句柄口径,AOI 也不例外。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/types.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
// EntityID 是场景内实体的唯一句柄:高 16 位是场景/实例号,低 48 位是
// 实例内自增序号。卷二所有玩法系统复用这个口径(见共享类型表)。
type EntityID uint64
const (
entitySeqBits = 48
entitySeqMask = (uint64(1) << entitySeqBits) - 1
)
// MakeEntityID 把实例号与实例内序号拼成一个 EntityID。
func MakeEntityID(instance uint16, seq uint64) EntityID {
return EntityID(uint64(instance)<<entitySeqBits | (seq & entitySeqMask))
}
// Instance 取回高 16 位的实例号。
func (e EntityID) Instance() uint16 { return uint16(e >> entitySeqBits) }
// LocalSeq 取回低 48 位的实例内序号。
func (e EntityID) LocalSeq() uint64 { return uint64(e) & entitySeqMask }
// Vec2 是服务器侧的逻辑坐标(浮点),与客户端像素坐标分离。
type Vec2 struct{ X, Y float64 }
// GridSize 是九宫格的格边长(逻辑单位),全卷统一为 8.0。
const GridSize = 8.0
// within 判断 b 是否落在以 a 为圆心、r 为半径的圆内(闭区间)。
// 比较距离平方,省掉一次开方。
func within(a, b Vec2, r float64) bool {
dx, dy := a.X-b.X, a.Y-b.Y
return dx*dx+dy*dy <= r*r
}
三个约定值得注意。EntityID 高 16 位是实例号、低 48 位是实例内自增序号——同一张地图分线后,不同线里的实体靠高位就能区分,AOI 不必关心它们属于哪条线。坐标是 float64 的逻辑坐标,与客户端像素坐标彻底分离。GridSize = 8.0 既是格边长,也是全卷统一的 AOI 半径默认值。
九宫格查询与 O(1) 跨格移动
数据结构很小:一个 map[cell]map[EntityID]Vec2 存「每个格有哪些实体、各自在哪」,再加一个 map[EntityID]cell 记「每个实体现在在哪一格」。后者是移动性能的关键——有了它,实体移动时不用扫描任何格子就能定位自己当前所在的格,跨格时只要从旧格 delete、往新格 insert,是 O(1)。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/grid.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
import "math"
// cell 是九宫格里的一个格坐标。
type cell struct{ X, Y int }
// cellOf 把逻辑坐标映射到格坐标。用 Floor 保证负坐标也正确落格。
func cellOf(p Vec2) cell {
return cell{X: int(math.Floor(p.X / GridSize)), Y: int(math.Floor(p.Y / GridSize))}
}
// Grid 是九宫格 AOI:世界按 GridSize 切格,每个格维护一个实体集合。
// 查询「谁在我附近」只需扫描自己所在格与相邻 8 格(九宫格),复杂度与
// 世界总人数无关,只与局部密度有关。
type Grid struct {
cells map[cell]map[EntityID]Vec2 // 格 -> (实体 -> 当前位置)
where map[EntityID]cell // 实体 -> 当前所在格,移动时 O(1) 定位
}
func NewGrid() *Grid {
return &Grid{
cells: make(map[cell]map[EntityID]Vec2),
where: make(map[EntityID]cell),
}
}
// Add 把一个实体放进它所在格。重复 Add 同一 ID 会先摘掉旧记录。
func (g *Grid) Add(id EntityID, p Vec2) {
if old, ok := g.where[id]; ok {
g.detach(old, id)
}
c := cellOf(p)
m := g.cells[c]
if m == nil {
m = make(map[EntityID]Vec2)
g.cells[c] = m
}
m[id] = p
g.where[id] = c
}
// Move 更新实体位置:若跨格则从旧格摘下、挂到新格;同格内只改坐标。
func (g *Grid) Move(id EntityID, p Vec2) {
old, ok := g.where[id]
if !ok {
g.Add(id, p)
return
}
nc := cellOf(p)
if nc == old {
g.cells[old][id] = p
return
}
g.detach(old, id)
m := g.cells[nc]
if m == nil {
m = make(map[EntityID]Vec2)
g.cells[nc] = m
}
m[id] = p
g.where[id] = nc
}
// Remove 把实体从格子里摘掉。
func (g *Grid) Remove(id EntityID) {
if c, ok := g.where[id]; ok {
g.detach(c, id)
}
}
func (g *Grid) detach(c cell, id EntityID) {
m := g.cells[c]
delete(m, id)
if len(m) == 0 {
delete(g.cells, c) // 空格子及时回收,避免地图越大内存越胀
}
delete(g.where, id)
}
// Neighbors 返回九宫格内的其它实体(不含自己)。这是「九宫格 AOI」的核心查询。
func (g *Grid) Neighbors(id EntityID) []EntityID {
c, ok := g.where[id]
if !ok {
return nil
}
out := make([]EntityID, 0, 16)
for dx := -1; dx <= 1; dx++ {
for dy := -1; dy <= 1; dy++ {
for other := range g.cells[cell{c.X + dx, c.Y + dy}] {
if other != id {
out = append(out, other)
}
}
}
}
return out
}
// NeighborsInRadius 在九宫格基础上再按圆形半径过滤,得到更精确的兴趣集合。
// 半径不超过格边长时,扫 9 格就够;半径更大时按 ceil(r/GridSize) 扩圈。
func (g *Grid) NeighborsInRadius(id EntityID, r float64) []EntityID {
c, ok := g.where[id]
if !ok {
return nil
}
self := g.cells[c][id]
k := int(math.Ceil(r / GridSize))
out := make([]EntityID, 0, 16)
for dx := -k; dx <= k; dx++ {
for dy := -k; dy <= k; dy++ {
for other, p := range g.cells[cell{c.X + dx, c.Y + dy}] {
if other != id && within(self, p, r) {
out = append(out, other)
}
}
}
}
return out
}
查询 Neighbors 就是遍历自己格与相邻八格,把除自己以外的实体收集起来。因为每格实体数是局部密度、与全服人数无关,所以这个查询是常数级的。NeighborsInRadius 在此之上再按圆形半径过滤:九宫格只能保证「同一格带」的粗筛,真正的兴趣范围是一个圆,多一步 within 判断才能得到精确集合。detach 里还有一行 if len(m) == 0 { delete(g.cells, c) }——空格子必须及时回收;地图越大格子越多,如果只删实体不删空格,cells 这个 map 会随着玩家走过的每一格无限膨胀,这是九宫格最常见的隐形内存泄漏。
从邻居集合到进出事件
Neighbors 只回答「此刻谁在附近」。但网络层要的是事件:谁刚刚进入视野(发 CmdAoiAdd 0x0203),谁刚刚离开视野(发 CmdAoiRemove 0x0204)。这件事由 Watcher 负责:它给每个实体记一份「上一帧已看见谁」的集合,每次移动后与新集合求差,产出的 added/removed 正好就是要补发的事件。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:
// file: internal/aoi/watcher.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi
// Watcher 在九宫格之上维护「每个实体已经看见谁」的差集。移动时它把
// 「新进入视野」与「离开视野」算出来,正好对应协议的 CmdAoiAdd 0x0203
// 与 CmdAoiRemove 0x0204——AOI 结构只回答「谁在附近」,Watcher 才回答
// 「这一帧要补发哪些进出事件」。
type Watcher struct {
grid *Grid
known map[EntityID]map[EntityID]struct{}
}
func NewWatcher() *Watcher {
return &Watcher{grid: NewGrid(), known: make(map[EntityID]map[EntityID]struct{})}
}
// Add 把一个实体放进九宫格,并初始化它的已知集合。
func (w *Watcher) Add(id EntityID, p Vec2) {
w.grid.Add(id, p)
if w.known[id] == nil {
w.known[id] = make(map[EntityID]struct{})
}
}
// Move 更新位置,返回该实体本次新看见(added)与不再看见(removed)的实体。
// 顺序不保证稳定,调用方若需要确定性可自行排序。
func (w *Watcher) Move(id EntityID, p Vec2) (added, removed []EntityID) {
w.grid.Move(id, p)
cur := make(map[EntityID]struct{})
for _, other := range w.grid.Neighbors(id) {
cur[other] = struct{}{}
if _, ok := w.known[id][other]; !ok {
added = append(added, other)
}
}
for other := range w.known[id] {
if _, ok := cur[other]; !ok {
removed = append(removed, other)
}
}
w.known[id] = cur
return added, removed
}
// Remove 把实体彻底移出。别人对它的「看见」会在各自下次 Move 时自然收敛。
func (w *Watcher) Remove(id EntityID) {
w.grid.Remove(id)
delete(w.known, id)
}
测试 TestWatcherEnterLeave 覆盖了完整生命周期:两个玩家从远到近、再走远——靠近时双方各收到一次 added,离开时收到一次 removed,原地不动不产生任何事件,保证协议既不重复发也不漏发。全部 8 个用例(含 EntityID 位运算往返、跨格移动、半径过滤)在本机 go test ./internal/aoi/ 全绿。
对照:同一份九宫格在 Java 21 上
九宫格算法简单到可以逐行翻译。下面这段是 L2J——同一份九宫格逻辑的 Java 21 实现,用 JDK 21 容器真跑(本机 JDK 只有 8,不能拿它冒充):
// file: AoiGrid.java
// tier: L2J
// verify: docker run --rm -v "$HOME/.aegis-java/ch04:/w" -w /w eclipse-temurin:21-jdk sh -c "javac AoiGrid.java && java AoiGrid"
// AoiGrid.java — JDK 21,九宫格 AOI 的 Java 对照实现,算法与 Go 版一致。
import java.util.*;
public final class AoiGrid {
static final double GRID = 8.0;
public static void main(String[] args) {
int n = 5000; double w = 400.0, r = 8.0;
Random rnd = new Random(12345);
HashMap<Long, HashMap<Long, double[]>> cells = new HashMap<>();
HashMap<Long, int[]> where = new HashMap<>();
for (int i = 0; i < n; i++) {
double x = rnd.nextDouble() * w, y = rnd.nextDouble() * w;
int cx = (int) Math.floor(x / GRID), cy = (int) Math.floor(y / GRID);
cells.computeIfAbsent(key(cx, cy), z -> new HashMap<>()).put((long) i, new double[]{x, y});
where.put((long) i, new int[]{cx, cy});
}
long sink = 0;
for (int warm = 0; warm < 5; warm++) // 预热:让 JIT 稳定
for (int i = 0; i < n; i++) sink += neighbors(cells, where, i, r).size();
long best = Long.MAX_VALUE;
for (int round = 0; round < 20; round++) {
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) sink += neighbors(cells, where, i, r).size();
best = Math.min(best, System.nanoTime() - t0);
}
System.out.printf("ns/query=%.0f avgSet=%.2f%n",
(double) best / n, (double) sink / (5L * n + 20L * n));
}
static long key(int x, int y) { return ((long) x << 32) ^ (y & 0xffffffffL); }
static List<Long> neighbors(HashMap<Long, HashMap<Long, double[]>> cells, HashMap<Long, int[]> where, long id, double r) {
int[] c = where.get(id);
double[] self = cells.get(key(c[0], c[1])).get(id);
List<Long> out = new ArrayList<>(16);
double r2 = r * r;
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++) {
var cell = cells.get(key(c[0] + dx, c[1] + dy));
if (cell == null) continue;
for (var e : cell.entrySet()) {
if (e.getKey() == id) continue;
double ddx = self[0] - e.getValue()[0], ddy = self[1] - e.getValue()[1];
if (ddx * ddx + ddy * ddy <= r2) out.add(e.getKey());
}
}
return out;
}
}
本机(Apple M1 Pro,JDK 21 容器 / Go 1.26.0)实跑对照:Go 版单次查询约 890ns,Java 版约 1250ns;两边的平均兴趣集合都是 6.2 人左右——密度一致,说明两份实现算的是同一件事。Java 略慢的原因不是 JIT,而是 HashMap<Long, Vec2> 对 long 与数组的装箱:Go 的 map[EntityID]Vec2 键值都是值类型,不产生堆对象。这组数字是同机相对量级,绝对数值不可移植。
本节小结 / 实测结论
九宫格 AOI 到这里就完整了:切格、常数级查询、O(1) 跨格移动、进出事件差集,加上一份 Java 对照,全部本机跑通。它的复杂度只与局部密度有关、与全服人数无关,这正是它成为 MMO 默认选择的原因。但九宫格有一个前提假设:密度在空间上大致均匀。如果地图是一大片空地加一个挤满人的主城,主城那几个格会塞进成千上万个实体,九宫格退化成线性扫描——而把格边长调小又会让空旷区浪费大量格子。下一节换一种不依赖固定格边长的结构:十字链表 AOI,用两条按坐标排序的链表把「附近」表达成「两个区间取交」。
阅读导航:上一节:目录 · 下一节:4.2 十字链表 AOI 从零实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。