3.2 兴趣管理与 AOI
上一节的快照链路默认「服务端把范围内全部实体都发给每个客户端」。这在实体少的时候没问题,可一旦地图铺开、实体上百,每个客户端都要为半张地图上根本看不见的东西付带宽和内存。**兴趣管理(interest management)**就是砍掉这部分浪费:只把「这个客户端看得见、且对它有意义」的实体发给它。做兴趣管理用的空间结构统称 AOI(Area of Interest,兴趣区域)。
先划清边界:AOI 的空间结构本身跑在服务端(只有服务端知道全局实体位置),它怎么划格子、怎么建索引,站内《游戏服务端编程实践》讲得比本节细。本节只讲客户端这一侧要做什么——拿到「进入 / 离开视野」的信号后,节点怎么增删、全量怎么补、边界抖动怎么压。服务端侧只给消息约定。
3.2.1 从「全发」到「按需」
不做 AOI 的世界模型是「一人一世界」:服务端把全部实体塞给每个客户端,客户端全建节点。它的成本随「实体总数 × 客户端数」线性上涨,而且涨得毫无价值——玩家 A 在地图最左端,地图最右端的宝箱对他毫无意义。
AOI 把它改成「每人只收自己视野内的实体」,收益有三个层次:
- 带宽:每帧下发的实体数从「全局」降到「视野内」,通常差一个数量级。
- 客户端内存与节点数:视野外的实体不建节点、不占对象池。
- 信息公平:玩家拿不到看不见的对手的精确坐标,天然少了一类作弊面(第 8 章展开)。
3.2.2 四种空间划分方案与各自代价
服务端选哪种结构,决定它算「谁该看见谁」的成本。客户端不必实现它们,但要理解不同结构如何影响自己收到的内容——这决定了客户端要处理多大的进出视野流量。
| 方案 | 怎么划分 | 查询代价 | 更新代价 | 代价与坑 |
|---|---|---|---|---|
| 九宫格 | 把地图切成固定大小的格子 | O(1) 定位到格,看邻域 9 格 | 实体跨格时移动索引 | 格子尺寸是死结:大了浪费、小了跨格频繁 |
| 十字链表 | 每个实体挂进所在行、列两条有序链表 | 沿链表扫描视野 | 插入 / 删除 O(1) | 最省内存,但有序维护与边界处理琐碎 |
| 四叉树 | 递归四分,密度高的区域分得更细 | O(log n) 到 O(n) | 分裂 / 合并有开销 | 实体分布不均时退化;动态更新成本高 |
| 视口矩形 | 不建全局结构,直接对候选集做矩形判定 | O(候选集) | 无 | 最简;只适合实体数不大或已有粗筛的场景 |
前三种是服务端为了在大世界里快速找邻居而建的索引;第四种「视口矩形」其实是客户端最常见的做法——客户端本来就只关心「哪些实体落在我这块屏幕矩形里」,直接对收到的候选集做矩形判定即可,不必维护任何全局结构。本节 L1 实现的就是第四种。
3.2.3 客户端侧要做什么
服务端算出「A 该看见 B、C」后,下发给 A 的不再是「全部实体」,而是增量式的进出信号:本帧谁进入了视野、谁离开了视野。客户端据此做三件事:
- 进入视野:为新实体建节点(或从对象池取一个),并先要一次全量。为什么?因为 AOI 之后服务端只对该实体发增量,客户端手里没有基线,增量叠不上去。所以「进入视野」这一步必须触发一次针对该实体的全量同步——这正是 3.1.8 里
apply遇到未知 id 时return的那个缺口。 - 离开视野:停用节点、回收进池,但保留最后一次状态片刻(用于淡出动画),然后才真正清理。
- 维持视野集:本地记一份「当前可见 id 集合」,每次拿它和新的可见集做差,才能知道谁是「刚进入」、谁是「刚离开」。
第 3 点是本节 L1 的核心——进出视野差集。
客户端和服务端在 AOI 上的接口约定很薄,只有两条消息(服务端实现见站内《游戏服务端编程实践》与 game/server/ 专题,本节只给约定):
服务端下发:
| 字段 | 类型 | 含义 |
|---|---|---|
type | string | 固定 "aoi" |
frame | uint16 | 本次进出生效的逻辑帧号 |
enter[] | uint8[] | 本帧进入视野的实体 id |
leave[] | uint8[] | 本帧离开视野的实体 id |
客户端上报:
| 字段 | 类型 | 含义 |
|---|---|---|
type | string | 固定 "aoi.request" |
id | uint8 | 请求全量同步的实体 id |
客户端收到 enter[] 后,对每个新 id 发一条 aoi.request,等服务端回一帧针对该实体的全量快照(3.1 的格式,full: true)再建节点。先请求全量、再渲染,避免出现「节点建好了但位置是默认值」的闪烁。
3.2.4 边界抖动与滞回带
一个实体在地图边界来回微动时,会反复穿越「可见 / 不可见」的判定线,导致节点被反复创建又回收、全量被反复请求。这叫边界抖动(boundary thrashing)。
解法是滞回带(hysteresis):进入和离开用两条不同的边界线。进入用内矩形(视口本身),离开用外矩形(视口各向外扩一段 hysteresis)。于是:
- 一个实体要进入,必须真正走进视口内部;
- 已经可见的实体要离开,必须退到外矩形之外。
中间那圈滞回带里,实体的可见状态保持不变。实体在边界上来回蹭,只要不蹭出外矩形,就不会触发任何进出——抖动被吃掉。
滞回带的宽度怎么定?它要盖住「一个实体在两帧之间可能移动的最大距离」,否则快速移动的实体会一帧跨过整条滞回带,抖动依旧发生。以《方块冲刺》为例,跑酷角色最快约每秒 400 像素,60 Hz 下一帧最多走 6.7 像素;取 32 像素(约一个身位)就有足够余量。带宽充裕时可以取大一些,代价是「视野边缘的实体多留一会儿」,内存占用略高。这条参数必须按实际素材尺寸与速度实测微调,没有普适值。
3.2.5 L1:inView 与进出视野差集
下面是纯 TypeScript 实现,零 cc 依赖,直接单测可跑:
// mini2d/net/aoi.ts —— 纯 TS,无 cc 依赖
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export interface Point {
x: number;
y: number;
}
/** 点是否落在矩形内(含边界) */
export function inRect(p: Point, r: Rect): boolean {
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
}
/**
* 带滞回带的视野判定。
* 已可见:只有移出「外扩 hysteresis 的外矩形」才算离开;
* 不可见:必须进入「内矩形(视口本身)」才算进入。
*/
export function inView(p: Point, view: Rect, hysteresis: number, wasVisible: boolean): boolean {
if (wasVisible) {
const outer: Rect = {
x: view.x - hysteresis,
y: view.y - hysteresis,
w: view.w + hysteresis * 2,
h: view.h + hysteresis * 2,
};
return inRect(p, outer);
}
return inRect(p, view);
}
export interface AoiDiff {
enter: number[];
leave: number[];
}
/** 计算「上一帧可见集 → 这一帧可见集」的进出差集 */
export function diffView(
prev: Set<number>,
positions: Map<number, Point>,
view: Rect,
hysteresis: number,
): AoiDiff {
const enter: number[] = [];
const leave: number[] = [];
const now = new Set<number>();
for (const [id, p] of positions) {
if (inView(p, view, hysteresis, prev.has(id))) {
now.add(id);
if (!prev.has(id)) {
enter.push(id);
}
}
}
for (const id of prev) {
if (!now.has(id)) {
leave.push(id);
}
}
return { enter, leave };
}
关键在 inView 的第四个参数 wasVisible:同一个点,可见状态不同,判定结果可以不同。这正是滞回带的实现方式——不是两套矩形各判一次再合并,而是把「上一次可见性」当作输入,一次性得出这次的结果。
配 vitest 断言,把「滞回带生效」和「差集只报变化项」两条钉住:
import { describe, expect, it } from 'vitest';
import { diffView, inView, type Rect } from './aoi';
const view: Rect = { x: 0, y: 0, w: 100, h: 100 };
describe('AOI 视野与滞回带', () => {
it('已可见的实体,需越过外扩带才离开', () => {
const nearEdge = { x: 105, y: 50 }; // 出内矩形、但在 10px 滞回带内
expect(inView(nearEdge, view, 10, true)).toBe(true); // 仍算可见,不触发离开
expect(inView(nearEdge, view, 10, false)).toBe(false); // 本不可见则不算进入
const far = { x: 120, y: 50 }; // 越过外扩带
expect(inView(far, view, 10, true)).toBe(false);
});
it('进出差集只报变化项', () => {
const prev = new Set<number>([1, 2]);
const positions = new Map<number, { x: number; y: number }>([
[1, { x: 50, y: 50 }], // 仍在视野
[2, { x: 200, y: 50 }], // 离开
[3, { x: 20, y: 20 }], // 进入
]);
const d = diffView(prev, positions, view, 10);
expect(d.enter).toEqual([3]);
expect(d.leave).toEqual([2]);
});
});
第一条断言里 inView(nearEdge, view, 10, true) === true 而 inView(nearEdge, view, 10, false) === false——同一个坐标、同一个视口,仅因「上一次是否可见」而结论相反,这就是滞回带的定义。第二条断言验证差集:id 1 没变(不进不出)、id 2 出了、id 3 进了,结果恰好是 enter: [3]、leave: [2]。
3.2.6 L2:按进出视野管理节点
拿到差集后,客户端用对象池接住进出信号:进入时复用节点,离开时停用回收:
import { _decorator, Component, Node, Prefab, instantiate } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('AoiViewPool')
export class AoiViewPool extends Component {
@property(Prefab)
public entityPrefab: Prefab | null = null;
private readonly free: Node[] = [];
private readonly live = new Map<number, Node>();
/** 实体进入视野:优先复用池中节点,没有则实例化 */
onEnter(id: number, x: number, y: number): void {
if (this.live.has(id)) {
return;
}
let node = this.free.pop();
if (!node && this.entityPrefab) {
node = instantiate(this.entityPrefab);
node.parent = this.node;
}
if (!node) {
return;
}
node.active = true;
node.setPosition(x, y, 0);
this.live.set(id, node);
}
/** 实体离开视野:停用并回收,不销毁 */
onLeave(id: number): void {
const node = this.live.get(id);
if (!node) {
return;
}
node.active = false;
this.live.delete(id);
this.free.push(node);
}
}
两个刻意的选择:onLeave 用 node.active = false 而不是 destroy()——销毁会在下一帧进入视野时重新实例化,进出频繁时开销很大,停用回收则把这份开销摊平。onEnter 里 if (this.live.has(id)) return; 是防重入:差集理论上不会把同一个 id 连报两次,但网络乱序下重复的进出信号并不罕见(第 5 章的超时重传会讲),幂等的入口比事后去重省心。
3.2.7 《方块冲刺》里的取舍:AOI 真的值吗
诚实地说:双人竞速里,AOI 的价值不在两名玩家身上。同屏就俩人,玩家之间本来就该互相可见,砍不掉任何东西。如果只看「两名玩家互相同步」,AOI 是纯粹的过度设计。
它的真实价值在三处:
- 道具与特效:跑道上撒了上百个金币、加速带、装饰粒子,大部分在屏幕外。AOI 让这些只在进入视野时才下发与建节点——这是实体数真正大的地方。
- 未来多人:今天是双人竞速,明天可能就是 8 人混战或带观众。把 AOI 的接口先定好,扩展时不用重写同步层。
- 信息公平:即便是双人,也顺手挡住了「通过抓包读对手精确坐标」这类作弊(第 8 章)。
所以工程决策是:接口按 AOI 设计,参数按双人调。视口矩形取「摄像机可视范围 + 一小圈缓冲」,hysteresis 取 32 像素左右(约一个身位,需按你的素材尺寸实测微调)。这样既不浪费,又给未来留了余地。
到这里,客户端已经能「只收看得见的、按需建节点」。但每帧下发的包到底多大、压到多少字节才够用、怎么把数值塞进尽量少的比特——这些还没算过。下一节把带宽预算摊开算清,并写出位打包与量化的可跑实现。
阅读导航:上一节:3.1 快照与增量同步 · 下一节:3.3 带宽预算与压缩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。