同一帧里,谁该看见谁
《裂隙战场》一局十个人,地图只有一条中路和两侧草丛;《泡泡工坊》一个房间八个人,地图是一块带障碍的方形平台;《群岛纪元》一个战区同时在线四万人,主城和野外连成一张大地图。三款产品都要回答同一个问题:这一帧里,A 应该收到谁的状态。答案却不是同一个。
北辰工作室最早想把《裂隙战场》的九宫格直接复用到《群岛纪元》。原型跑通后,主城战出现两个现象:单个格子挤进两百多个玩家(示例值),每次跨格移动都要给整格广播;地图边缘的行军单位因为坐标是负数,被 int 截断后落进了错误的格子。第二个问题改一行取整函数就能修,第一个问题说明选型判据错了:九宫格在十人局里便宜,在大地图上会把“视野查询”退化成“整格广播”。
本节不推导 AOI 数据结构,而是给出一条可复用的判断顺序:先算消息量,再看同步模型,最后才比较实现复杂度。数据结构的实现细节可以回到 MMO 四卷的相关章节,这里只讨论什么规模该选什么、切换的代价是什么。
先算消息量,再谈数据结构
AOI 的成本不是“查询一次要遍历多少节点”,而是“每秒要生成、序列化、发送多少条可见性变化”。用一个粗公式先把量级摆出来:
每秒消息数 ≈ 在线实体数 N × 平均可见实体数 K × 同步频率 f
每秒字节数 ≈ 每秒消息数 × 单条消息字节数 B
三款产品按这个公式估算(示例值,来自设计文档的容量假设,不是线上测量):
| 产品 | N | K | f | B | 预期字节/秒 | 结论 |
|---|---|---|---|---|---|---|
| 《裂隙战场》5v5 | 10 | 9 | 10 | 120 | 108 KB | 单进程网格足够 |
| 《泡泡工坊》房间 | 8 | 7 | 5 | 80 | 22 KB | 网格甚至可以不建索引 |
| 《群岛纪元》战区 | 40000 | 30 | 2 | 100 | 24 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 分线、副本与主城/野外 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。