《游戏服务端架构·核心系统与契约》4.1 AOI 选型:九宫格、十字链表与空间索引

《裂隙战场》《群岛纪元》《泡泡工坊》三个场景的 AOI 选型不能只看数据结构排名。本节先按在线人数、视野半径和同步频率估算消息量,再对比九宫格、十字链表与空间索引的实现成本、边界穿越和动态实体适应性,并给出从网格切换到空间索引的触发条件与可运行代码。

同一帧里,谁该看见谁

《裂隙战场》一局十个人,地图只有一条中路和两侧草丛;《泡泡工坊》一个房间八个人,地图是一块带障碍的方形平台;《群岛纪元》一个战区同时在线四万人,主城和野外连成一张大地图。三款产品都要回答同一个问题:这一帧里,A 应该收到谁的状态。答案却不是同一个。

北辰工作室最早想把《裂隙战场》的九宫格直接复用到《群岛纪元》。原型跑通后,主城战出现两个现象:单个格子挤进两百多个玩家(示例值),每次跨格移动都要给整格广播;地图边缘的行军单位因为坐标是负数,被 int 截断后落进了错误的格子。第二个问题改一行取整函数就能修,第一个问题说明选型判据错了:九宫格在十人局里便宜,在大地图上会把“视野查询”退化成“整格广播”。

本节不推导 AOI 数据结构,而是给出一条可复用的判断顺序:先算消息量,再看同步模型,最后才比较实现复杂度。数据结构的实现细节可以回到 MMO 四卷的相关章节,这里只讨论什么规模该选什么、切换的代价是什么。

先算消息量,再谈数据结构

AOI 的成本不是“查询一次要遍历多少节点”,而是“每秒要生成、序列化、发送多少条可见性变化”。用一个粗公式先把量级摆出来:

每秒消息数 ≈ 在线实体数 N × 平均可见实体数 K × 同步频率 f
每秒字节数 ≈ 每秒消息数 × 单条消息字节数 B

三款产品按这个公式估算(示例值,来自设计文档的容量假设,不是线上测量):

产品NKfB预期字节/秒结论
《裂隙战场》5v510910120108 KB单进程网格足够
《泡泡工坊》房间8758022 KB网格甚至可以不建索引
《群岛纪元》战区4000030210024 MB必须分层、聚合、降频

最后一行是关键:如果不做任何聚合,四万人战区仅状态同步就要 24 MB/s 的出站流量,还没算网关复制和协议头。这个量级下,换一棵更“高级”的树只能降低查询 CPU,降不了字节数。AOI 决定谁看得见,同步模型决定看见什么、多久看一次。 两者必须一起选。

三种方案,判据不是“谁更高级”

方案查询复杂度实现复杂度动态实体适应性边界穿越适用规模
九宫格O(9 格内实体)低,一张哈希表好,改坐标即改格跨格时做前后视野差单场景几十到几百人
十字链表O(视野内实体)中,四向链表维护差,频繁增删要重连沿链表滚动,天然连续均匀分布、移动平滑
空间索引(四叉树/R 树)O(log n + k)高,分裂合并与并发中,实体密集时退化需要范围查询与重建上万动态实体、非均匀

十字链表的优点常被夸大。它把“谁在我右边”做成指针,移动时可以沿链表滑动,确实省掉了格子哈希;但实体频繁上下线、瞬移、跨区域时,链表节点的插入删除和空洞回收会把实现复杂度推高。它适合《裂隙战场》这种移动连续、实体数量稳定的场景,不适合《群岛纪元》这种大量离线队列和迁城。

空间索引适合“查询范围差异很大”的世界,例如狙击视野两千米、近战视野五米。代价是分裂阈值、对象池和删除标记都要自己处理;一旦实体分布严重不均,四叉树会退化成链表。如果实体密度均匀、视野半径固定,九宫格通常是最便宜的正确答案。

状态同步与事件同步是两层

同一个 AOI 结果可以喂给三种同步模型,代价完全不同:

模型发送内容带宽一致性典型场景
全量快照视野内所有实体状态最高简单,丢包可自愈小房间、低频率
增量状态只发变化的字段中需要基线与序列号MOBA、FPS
事件同步只发“进入/离开/技能”最低客户端自己推演SLG、大世界

《群岛纪元》最终选事件同步:AOI 只负责产生“谁进入谁视野”,位置由客户端按行军路径推演,服务端在关键节点校正。这样每秒 24 MB 的估算被压到“行军开始、到达、战斗结算”几个事件,代价是客户端要接受短暂的位置偏差。

《裂隙战场》选增量状态:十个人、十赫兹,带宽不是瓶颈,确定性和观战回放更重要。不要因为事件同步省带宽就把它用在竞技品类;事件丢失后的补发和顺序,比多传几百 KB 难处理得多。

选定方案与边界

三款产品最终都用九宫格起步,但格子尺寸和切换条件不同:

  • 《裂隙战场》:格子边长等于一个屏幕,约 20 米(示例值),视野固定 3×3。
  • 《泡泡工坊》:格子边长 8 米,房间本来就小,索引只用于判断碰撞候选。
  • 《群岛纪元》:格子边长 50 米,战区级再叠一层地图分片,AOI 只在片内运行。

从九宫格切换到空间索引的触发条件写成可观测指标,而不是“感觉卡了”:

指标阈值(示例值)含义
单格实体数 p99大于 20格子粒度过粗,广播被整格放大
跨格移动占比大于 30%边界抖动导致视野差频繁重算
AOI 查询 p99大于 2 ms单帧预算被查询吃掉
视野半径种类大于 3 种固定 3×3 无法表达差异视野

前两个指标先调格子尺寸,仍然超标才考虑空间索引。换索引是最后手段,不是性能问题的第一反应。

可运行片段:3×3 网格与对称差

下面的 internal/aoiindex 把格子映射、邻域查询、进入/离开视野的对称差写成可测试的包。它故意只做“谁看见谁”,不掺同步协议;这样换同步模型时不用重写索引。

// file: internal/aoiindex/aoiindex.go
// tier: L1
// verify: go test ./internal/aoiindex/
package aoiindex

import (
	"errors"
	"fmt"
	"math"
	"sort"
)

var (
	ErrCellSize    = errors.New("aoiindex: cell size must be positive")
	ErrEmptyID     = errors.New("aoiindex: empty entity id")
	ErrDuplicateID = errors.New("aoiindex: entity already placed")
	ErrNotPlaced   = errors.New("aoiindex: entity is not placed")
)

// Point is a continuous world position. The grid converts it to an integer
// cell with math.Floor, so negative coordinates are valid and a cell is
// half-open: [x*size, (x+1)*size).
type Point struct{ X, Y float64 }

// Cell is an integer grid coordinate.
type Cell struct{ X, Y int }

// Delta describes one AOI update. See and Unsee are the subject's gained and
// lost neighbours; SeenBy and UnseenBy are the entities whose view of the
// subject changed. Lists are sorted by id so callers can diff and log them.
type Delta struct {
	Subject  string
	See      []string
	Unsee    []string
	SeenBy   []string
	UnseenBy []string
}

// Empty reports whether the update changed nobody's view.
func (d Delta) Empty() bool {
	return len(d.See)+len(d.Unsee)+len(d.SeenBy)+len(d.UnseenBy) == 0
}

// Grid is a uniform spatial hash. Visibility is defined as the 3x3 block of
// cells around the subject, which keeps the common query a local map walk.
type Grid struct {
	cellSize float64
	cells    map[Cell]map[string]struct{}
	at       map[string]Cell
	pos      map[string]Point
}

// NewGrid builds an empty grid. Cell size is a product decision: too small
// multiplies cells and events, too large makes the 3x3 neighbourhood noisy.
func NewGrid(cellSize float64) (*Grid, error) {
	if cellSize <= 0 || math.IsNaN(cellSize) || math.IsInf(cellSize, 0) {
		return nil, ErrCellSize
	}
	return &Grid{
		cellSize: cellSize,
		cells:    make(map[Cell]map[string]struct{}),
		at:       make(map[string]Cell),
		pos:      make(map[string]Point),
	}, nil
}

// CellOf maps a position to its half-open cell.
func (g *Grid) CellOf(p Point) Cell {
	return Cell{X: int(math.Floor(p.X / g.cellSize)), Y: int(math.Floor(p.Y / g.cellSize))}
}

// Neighborhood returns the 3x3 block in a deterministic order. Duplicate cells
// cannot occur, so callers may use the result directly for fanout estimates.
func Neighborhood(c Cell) []Cell {
	out := make([]Cell, 0, 9)
	for y := c.Y - 1; y <= c.Y+1; y++ {
		for x := c.X - 1; x <= c.X+1; x++ {
			out = append(out, Cell{X: x, Y: y})
		}
	}
	return out
}

// Visible returns the ids currently sharing the 3x3 neighbourhood with id.
func (g *Grid) Visible(id string) ([]string, error) {
	c, ok := g.at[id]
	if !ok {
		return nil, fmt.Errorf("%w: %s", ErrNotPlaced, id)
	}
	return g.visibleAt(c, id), nil
}

func (g *Grid) visibleAt(c Cell, exclude string) []string {
	out := make([]string, 0, 8)
	for _, n := range Neighborhood(c) {
		for other := range g.cells[n] {
			if other != exclude {
				out = append(out, other)
			}
		}
	}
	sort.Strings(out)
	return out
}

// Place inserts a new entity and reports who can already see it. Placing the
// same id twice is a caller bug, not an update; use Move for updates.
func (g *Grid) Place(id string, p Point) (Delta, error) {
	if id == "" {
		return Delta{}, ErrEmptyID
	}
	if _, ok := g.pos[id]; ok {
		return Delta{}, fmt.Errorf("%w: %s", ErrDuplicateID, id)
	}
	c := g.CellOf(p)
	g.pos[id] = p
	g.at[id] = c
	if g.cells[c] == nil {
		g.cells[c] = make(map[string]struct{})
	}
	g.cells[c][id] = struct{}{}
	visible := g.visibleAt(c, id)
	return Delta{Subject: id, See: visible, SeenBy: visible}, nil
}

// Move updates a position. Moving inside one cell changes no view; crossing a
// cell boundary produces the full symmetric difference of the two 3x3 blocks.
func (g *Grid) Move(id string, p Point) (Delta, error) {
	if _, ok := g.pos[id]; !ok {
		return Delta{}, fmt.Errorf("%w: %s", ErrNotPlaced, id)
	}
	oldCell := g.at[id]
	newCell := g.CellOf(p)
	if oldCell == newCell {
		g.pos[id] = p
		return Delta{Subject: id}, nil
	}
	oldVisible := g.visibleAt(oldCell, id)
	delete(g.cells[oldCell], id)
	if len(g.cells[oldCell]) == 0 {
		delete(g.cells, oldCell)
	}
	g.pos[id] = p
	g.at[id] = newCell
	if g.cells[newCell] == nil {
		g.cells[newCell] = make(map[string]struct{})
	}
	g.cells[newCell][id] = struct{}{}
	newVisible := g.visibleAt(newCell, id)
	see, unsee := diff(oldVisible, newVisible)
	return Delta{Subject: id, See: see, Unsee: unsee, SeenBy: see, UnseenBy: unsee}, nil
}

// Remove deletes an entity. Its old neighbours lose sight of it; the subject
// itself no longer has a view, so only Unsee and UnseenBy carry ids.
func (g *Grid) Remove(id string) (Delta, error) {
	c, ok := g.at[id]
	if !ok {
		return Delta{}, fmt.Errorf("%w: %s", ErrNotPlaced, id)
	}
	visible := g.visibleAt(c, id)
	delete(g.cells[c], id)
	if len(g.cells[c]) == 0 {
		delete(g.cells, c)
	}
	delete(g.pos, id)
	delete(g.at, id)
	return Delta{Subject: id, Unsee: visible, UnseenBy: visible}, nil
}

// Fanout counts entities in the 3x3 block around p, including any entity at p.
// It is the cheap estimate used before sending a map event.
func (g *Grid) Fanout(p Point) int {
	n := 0
	for _, c := range Neighborhood(g.CellOf(p)) {
		n += len(g.cells[c])
	}
	return n
}

// Len is the number of placed entities.
func (g *Grid) Len() int { return len(g.pos) }

func diff(old, next []string) (added, removed []string) {
	inOld := make(map[string]bool, len(old))
	for _, id := range old {
		inOld[id] = true
	}
	inNext := make(map[string]bool, len(next))
	for _, id := range next {
		inNext[id] = true
		if !inOld[id] {
			added = append(added, id)
		}
	}
	for _, id := range old {
		if !inNext[id] {
			removed = append(removed, id)
		}
	}
	return added, removed
}

几个实现取舍值得说明:

  • CellOf 用 math.Floor 而不是整数除法。负坐标是大地图常态,Go 的整数除法向零截断,会把 -0.1 和 0.1 放进同一格。
  • 视线范围固定为 3×3。它无法表达“狙击手看得更远”,但换来查询路径稳定;差异视野应该由上层分层索引解决,而不是把格子改得忽大忽小。
  • Move 只在新旧格不同时计算对称差。同格移动仍然更新坐标,但不产生任何视野事件,这是最热路径上的关键优化。
  • Delta 同时给出“我新看见谁”和“谁新看见我”。服务端不需要给两端发同一条消息,但两侧的视图必须同时更新,否则会出现单向可见。
  • Fanout 是发送前的成本探针。地图事件广播前先问一次“这一片有多少人”,比事后看带宽曲线更早暴露热区。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/aoiindex
$ go vet ./internal/aoiindex/...
$ go test -count=1 ./internal/aoiindex/
ok  	gsarchlab/internal/aoiindex

5 个表驱动测试全部通过,覆盖负坐标取整、九格邻域、进入/离开视野的对称差、同格移动和非法输入。gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
格子过粗单格几百人,广播被放大按峰值密度调小格子,或拆热区
格子过细跨格频繁,事件抖动引入迟滞带,跨格后延迟一帧确认
负坐标截断地图边缘实体落错格math.Floor,并为边界写测试
瞬移未清理旧格残留幽灵实体移动接口统一走 Move,禁止直接改坐标
单向可见A 看得见 B,B 看不见 A每次更新同时应用 See 与 SeenBy
删除遗漏断线玩家仍在他人视野断线走 Remove,并广播 UnseenBy

最容易被省略的是“删除路径”。上线、移动都有测试,断线清理却常常只在联调时手动点一次;一个残留实体能让整格玩家持续收到无效状态,直到进程重启。

常见错误

第一,用消息量很小的场景去证明大数据结构。十人局里九宫格和四叉树的差异在微秒级,真正决定体验的是同步频率和协议大小。

第二,把 AOI 当成同步模型。AOI 只回答“谁看得见”,不回答“发快照还是发事件”;两者混在一起,后面换同步方式要重写索引。

第三,只按平均密度选格子。主城、关卡、王城战的峰值密度可能是平均值的几十倍(示例值),格子尺寸要按峰值设计,或者允许热区单独配置。

第四,忽略边界抖动。实体在格子边线来回走,每帧都产生进入/离开事件;没有迟滞带时,客户端会看到名字闪烁。

第五,在 AOI 层做业务过滤。阵营、隐身、视野遮挡属于可见性规则,应该在 AOI 产出候选集之后过滤;把它塞进索引会让索引无法复用。

小结

AOI 选型的顺序是:先按 N×K×f×B 估算消息量,再确定状态同步还是事件同步,最后才在九宫格、十字链表和空间索引之间比较。对北辰工作室的三款产品,九宫格是共同起点;《群岛纪元》靠分片和事件同步控制量级,而不是靠更复杂的树。

AOI 解决的是“同一场景内谁看见谁”。下一节往上一层:当一个场景装不下、或者主城和野外需要不同规则时,世界要怎么分成多条线、多个副本,玩家跨线时状态由谁持有。

阅读导航:上一节:3.3 实例分配、暖池与回收 · 下一节:4.2 分线、副本与主城/野外 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查