《Cocos 游戏联机实战》3.2 兴趣管理与 AOI

上一节把「收快照、叠状态、写节点」的链路打通,但默认每帧发全部实体。AOI 要回答的是:怎样只同步客户端看得见的东西。本节对比四种空间划分方案的代价,给出带滞回带的视野判定与「进出视野差集」的纯 TS 实现加 vitest 断言,并说明《方块冲刺》双人竞速里 AOI 的真实价值在哪。

3.2 兴趣管理与 AOI

上一节的快照链路默认「服务端把范围内全部实体都发给每个客户端」。这在实体少的时候没问题,可一旦地图铺开、实体上百,每个客户端都要为半张地图上根本看不见的东西付带宽和内存。**兴趣管理(interest management)**就是砍掉这部分浪费:只把「这个客户端看得见、且对它有意义」的实体发给它。做兴趣管理用的空间结构统称 AOI(Area of Interest,兴趣区域)。

先划清边界:AOI 的空间结构本身跑在服务端(只有服务端知道全局实体位置),它怎么划格子、怎么建索引,站内《游戏服务端编程实践》讲得比本节细。本节只讲客户端这一侧要做什么——拿到「进入 / 离开视野」的信号后,节点怎么增删、全量怎么补、边界抖动怎么压。服务端侧只给消息约定。

3.2.1 从「全发」到「按需」

不做 AOI 的世界模型是「一人一世界」:服务端把全部实体塞给每个客户端,客户端全建节点。它的成本随「实体总数 × 客户端数」线性上涨,而且涨得毫无价值——玩家 A 在地图最左端,地图最右端的宝箱对他毫无意义。

AOI 把它改成「每人只收自己视野内的实体」,收益有三个层次:

  1. 带宽:每帧下发的实体数从「全局」降到「视野内」,通常差一个数量级。
  2. 客户端内存与节点数:视野外的实体不建节点、不占对象池。
  3. 信息公平:玩家拿不到看不见的对手的精确坐标,天然少了一类作弊面(第 8 章展开)。

3.2.2 四种空间划分方案与各自代价

服务端选哪种结构,决定它算「谁该看见谁」的成本。客户端不必实现它们,但要理解不同结构如何影响自己收到的内容——这决定了客户端要处理多大的进出视野流量。

方案怎么划分查询代价更新代价代价与坑
九宫格把地图切成固定大小的格子O(1) 定位到格,看邻域 9 格实体跨格时移动索引格子尺寸是死结:大了浪费、小了跨格频繁
十字链表每个实体挂进所在行、列两条有序链表沿链表扫描视野插入 / 删除 O(1)最省内存,但有序维护与边界处理琐碎
四叉树递归四分,密度高的区域分得更细O(log n) 到 O(n)分裂 / 合并有开销实体分布不均时退化;动态更新成本高
视口矩形不建全局结构,直接对候选集做矩形判定O(候选集)无最简;只适合实体数不大或已有粗筛的场景

前三种是服务端为了在大世界里快速找邻居而建的索引;第四种「视口矩形」其实是客户端最常见的做法——客户端本来就只关心「哪些实体落在我这块屏幕矩形里」,直接对收到的候选集做矩形判定即可,不必维护任何全局结构。本节 L1 实现的就是第四种。

3.2.3 客户端侧要做什么

服务端算出「A 该看见 B、C」后,下发给 A 的不再是「全部实体」,而是增量式的进出信号:本帧谁进入了视野、谁离开了视野。客户端据此做三件事:

  1. 进入视野:为新实体建节点(或从对象池取一个),并先要一次全量。为什么?因为 AOI 之后服务端只对该实体发增量,客户端手里没有基线,增量叠不上去。所以「进入视野」这一步必须触发一次针对该实体的全量同步——这正是 3.1.8 里 apply 遇到未知 id 时 return 的那个缺口。
  2. 离开视野:停用节点、回收进池,但保留最后一次状态片刻(用于淡出动画),然后才真正清理。
  3. 维持视野集:本地记一份「当前可见 id 集合」,每次拿它和新的可见集做差,才能知道谁是「刚进入」、谁是「刚离开」。

第 3 点是本节 L1 的核心——进出视野差集。

客户端和服务端在 AOI 上的接口约定很薄,只有两条消息(服务端实现见站内《游戏服务端编程实践》与 game/server/ 专题,本节只给约定):

服务端下发:

字段类型含义
typestring固定 "aoi"
frameuint16本次进出生效的逻辑帧号
enter[]uint8[]本帧进入视野的实体 id
leave[]uint8[]本帧离开视野的实体 id

客户端上报:

字段类型含义
typestring固定 "aoi.request"
iduint8请求全量同步的实体 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 是纯粹的过度设计。

它的真实价值在三处:

  1. 道具与特效:跑道上撒了上百个金币、加速带、装饰粒子,大部分在屏幕外。AOI 让这些只在进入视野时才下发与建节点——这是实体数真正大的地方。
  2. 未来多人:今天是双人竞速,明天可能就是 8 人混战或带观众。把 AOI 的接口先定好,扩展时不用重写同步层。
  3. 信息公平:即便是双人,也顺手挡住了「通过抓包读对手精确坐标」这类作弊(第 8 章)。

所以工程决策是:接口按 AOI 设计,参数按双人调。视口矩形取「摄像机可视范围 + 一小圈缓冲」,hysteresis 取 32 像素左右(约一个身位,需按你的素材尺寸实测微调)。这样既不浪费,又给未来留了余地。

到这里,客户端已经能「只收看得见的、按需建节点」。但每帧下发的包到底多大、压到多少字节才够用、怎么把数值塞进尽量少的比特——这些还没算过。下一节把带宽预算摊开算清,并写出位打包与量化的可跑实现。

阅读导航:上一节:3.1 快照与增量同步 · 下一节:3.3 带宽预算与压缩 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画