一、问题:为什么不能把所有数据发给所有人
你竞拍游戏里目前 4 个玩家,服务端每秒向每个客户端同步所有玩家的状态。假设每条状态更新 200 字节、每秒 10 次:
4 个玩家 × 200 bytes × 10 Hz × 3 个其他玩家 = 24 KB/s per client完全没问题。
但如果这个游戏变成 100 人大厅呢?
100 × 200 × 10 × 99 = 19.8 MB/s per client ← 一个客户端就要 20MB/s服务端总出口:100 × 19.8 MB/s ≈ 2 GB/s ← 物理链路上限了而且大部分数据是垃圾——你在拍卖大厅的东边,完全不需要知道西边 50 米外那 30 个人在做什么。
这就是 AOI(Area of Interest,视野管理) 要解决的问题:对于每个玩家,只同步它”看得见”或”关心”的那部分实体。
二、AOI 的本质
AOI 是一个空间过滤器。输入是所有实体 + 一个”观察者”,输出是与这个观察者相关的实体子集。
输入:200 个 Player, 1000 个 NPC, 5000 个可交互物体, 观察者 = 玩家A ↓ AOI 过滤输出:15 个 Player, 50 个 NPC, 100 个可交互物体(玩家A 视野内的)两个核心指标:
- 精确度:该发的都发了吗(不漏)?不该发的都没发吗(不多发)?
- CPU 开销:查询本身花了多少时间?每秒要查询 N 次(N = 玩家数 × 同步频率)
三、经典算法一:九宫格(Grid AOI)
1 基本思想
把世界划分成等大的格子。每个实体知道自己当前在哪个格子里。查询时,只返回”观察者所在格子 + 周围 8 个邻居格子”里的实体。
┌───┬───┬───┐│ │ │ │├───┼───┼───┤│ │ 我│ │ ← "我"在第 5 格,查询范围是 1-9 全部九格├───┼───┼───┤│ │ │ │└───┴───┴───┘不相邻的格子里的实体全部跳过。2 数据结构
class GridAOI { // 每格存一组实体 std::unordered_map<GridCoord, std::unordered_set<EntityId>> mGrids; float mCellSize; // 格子的边长(如 20 米)
public: void OnEntityMove(EntityId id, Vec3 oldPos, Vec3 newPos) { GridCoord oldGrid = ToGrid(oldPos); GridCoord newGrid = ToGrid(newPos); if (oldGrid != newGrid) { mGrids[oldGrid].erase(id); // 搬出旧格 mGrids[newGrid].insert(id); // 搬入新格 } }
std::vector<EntityId> QueryAOI(Vec3 observerPos) { GridCoord center = ToGrid(observerPos); std::vector<EntityId> result; for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { GridCoord neighbor = { center.x + dx, center.y + dy }; auto it = mGrids.find(neighbor); if (it != mGrids.end()) { result.insert(result.end(), it->second.begin(), it->second.end()); } } } return result; }};3 优缺点
| 优点 | 缺点 |
|---|---|
| 实现极其简单,几十行代码 | 视野必须是矩形——但对大多数俯视角/平坦场景够用 |
| 查询 O(1)——只看 9 个格子 | 格子大小难调:太大=过多无关实体;太小=频繁跨格 |
| 移动时只有跨格才更新,静止实体零开销 | 高层建筑/垂直空间用 2D 格子搞不定 |
| 天然支持”远处的山也看得见”——把大山放进所有格子 | 实体密集区一个格子里几万人,退化 |
适用于:俯视角、平坦世界、实体密度相对均匀的场景。大部分 MMO 的 AOI 变体都用九宫格或其衍生。
四、经典算法二:十字链表(Cross-Linked List AOI)
1 基本思想
不按格子分,而是维护两条有序链表——X 轴一条、Z 轴一条。每个实体同时挂在两条链表中。
查询时,从观察者在 X 链表中的位置出发,向左/向右找,直到遇到距离超过视野范围的实体。同理在 Z 链表里找。取两个结果的交集:
X 链表(按 X 坐标排序): [实体A: x=5] → [实体B: x=12] → [我: x=15] → [实体C: x=18] → [实体D: x=30] 视野范围 10 米 → 从"我"向左找:B(距离3)、A(距离10,边界) → 从"我"向右找:C(距离3)、D(距离15,超出) X 结果集 = {A, B, C}
Z 链表同理:Z 结果集 = {A, C, E}
最终 AOI = X 结果集 ∩ Z 结果集 = {A, C}2 优缺点
| 优点 | 缺点 |
|---|---|
| 视野是精确半径,不是粗糙的九宫格边界 | 插入/删除需要维护两条链表 |
| 无格子大小调参问题 | 实体移动时需要在链表中重新定位(链表查找不高效) |
| 密集区不会有一格万人问题 | 实现复杂度高于九宫格 |
适用于:对 AOI 准确性要求高、实体密度极度不均匀的场景。
五、现代服务器的实际做法
1 大部分项目用九宫格就够了
九宫格的”视野边界不精确”在实际业务中通常不是问题——因为策划的体验需求本身就是模糊的(“大概 20 米范围内的玩家”),不是精确数学需求。而且九宫格的简单性带来了更少的 bug 和更好的性能。
不要过度设计。如果九宫格够用,就别上十字链表。
2 混合方案——大部分实际服务器的选择
静态大世界物体(建筑、地形、NPC) → 不通过 AOI 管理,按场景分块走 Streaming
动态玩家 → 九宫格初筛(极快,去掉 90% 无关玩家) → 精确距离检查(剩下 10% 中再过滤)
特殊实体(世界 Boss、活动 NPC、广播事件) → 不经过 AOI,直接全图广播或按特殊逻辑分发3 你的竞拍游戏的特殊性
竞拍游戏的空间其实不是核心维度——拍卖桌上,空间距离为零,所有玩家都”看得见”对方。这时候 AOI 的含义变成了信息维度上的可见性:
原始 AOI:空间距离 → 决定实体是否同步竞拍游戏的信息 AOI: ├── 公开信息(轮次、倒计时、结算结果)→ 全员同步 ├── 半公开信息(出价人数、某角色使用了技能)→ 全员同步,但不暴露具体数值 └── 私密信息(某人的具体出价、某人技能看到的情报)→ 仅自己这本质上也是 AOI——只是”视野”不是空间距离,而是信息权限。把九宫格的”格子距离”替换成”信息权限等级”,就是竞拍游戏的数据分发模型。
六、UE 的 NetRelevancy 体系
UE 在网络层内置了基础的 AOI 支持,通过 IsNetRelevantFor 实现:
bool AActor::IsNetRelevantFor( const AActor* RealViewer, // 实际观看的玩家 const AActor* ViewTarget, // 相机目标 const FVector& SrcLocation // 观察者位置) const { // 默认:距离判断 return (GetActorLocation() - SrcLocation).SizeSquared() < NetCullDistanceSquared;}你可以做的事情:
// 竞拍游戏里:AuctionResultActor 对所有玩家都是 relevantbool AAuctionResultActor::IsNetRelevantFor(...) const { return true; // 结算结果全员可见,不管在哪}
// 竞拍桌之外的大厅玩家:默认用距离// 不需要改,UE 默认就是距离相关性
// 角色技能的特殊信息:通过 RPC 定向推送,不靠 RelevancyUE 的 UReplicationGraph 进一步允许你做更细粒度的控制——指定哪些 Actor 由哪个 ReplicationNode 管理、每个 Node 有不同的同步策略(距离相关、始终同步、按玩家条件同步等)。这是为大项目准备的,你的 mini-game 数量级可能用不上——但理解它存在就好。
七、AOI 与服务端性能
1 同步频率不是越高越好
同步频率 = 每客户端每秒同步次数
10 Hz → 适合大部分游戏(每 100ms 一次更新,玩家几乎感知不到)20 Hz → 竞技游戏(如 CS/Valorant),需要更精确的命中检测1-5 Hz → 远处/不重要的实体,省带宽动态频率:近处的玩家 10Hz,远处的 3Hz,更远的 1Hz。九宫格里的距离天然可以做这个分层。
2 增量同步比全量更重要
AOI 另一面的关键优化是——已经同步过的实体,不需要再次全量发送:
实体 A 进入玩家 1 的 AOI: → 全量同步 A 的所有属性(位置、装备、状态)实体 A 在玩家 1 的 AOI 里继续存在: → 只同步 A 变化了的属性(位置变了,装备没变)实体 A 离开玩家 1 的 AOI: → 通知玩家 1 "A 消失了"(一个很小的删除包)这和 UE 的增量复制(Replicated 属性的脏标记)是一脉相承的思路,只是在 AOI 层面加了”进入/离开视野”的生命周期事件。
八、总结
| 概念 | 一句话 |
|---|---|
| AOI 问题 | 100 人互相发状态是 O(n²) 带宽,必须过滤 |
| 九宫格 | 世界分格子,只发相邻 9 格内的实体——80% 的游戏够用了 |
| 十字链表 | X/Z 双有序链表求交集——更精确但更复杂 |
| 混合方案 | 九宫格初筛 + 精确距离过滤 + 特殊实体特殊处理 |
| UE NetRelevancy | IsNetRelevantFor + UReplicationGraph |
| 竞拍游戏的信息 AOI | 不是空间距离,是信息权限等级 |
| 动态频率 | 近处 10Hz、远处 1Hz——带宽省一大半 |
AOI 是服务端最核心的带宽优化手段。理解了它,你的游戏才能在”4 人房”和”100 人大厅”之间伸缩。