一、问题:MMO 的世界有多大?
你从暴风城贸易区走向英雄谷,迎面走来十几个玩家,旁边有几个 NPC 卫兵,天上飞着狮鹫,远处还有人 PvP 打得正欢。你的客户端能看到这一切,是因为服务器不断在计算什么实体在你附近、它们的状态如何、能不能被你看见。
但服务器不是同时加载了整个世界的------艾泽拉斯大陆的尺寸大约是 34,133 码 × 34,133 码,相当于北京天安门到天津的直线距离。如果每帧 Update 都遍历整张地图来碰撞检测,任何服务器都扛不住。
所以有一个核心问题:当几十个玩家同时站在暴风城门口,服务器怎么知道「我」附近有什么?

直觉上你会想:遍历所有对象呗。但更现实的问题是:"所有对象"到底有多少?
- 数千个 NPC 和怪物(每个区域巡逻/重生)
- 上千个可交互的 GameObjects(矿点、草药、门、宝箱)
- 几十个玩家(每个带着宠物和 DOT)
- 连续不断的动态对象(法术特效、弹道、钓鱼浮漂)
答案只有一个:空间分区。AzerothCore 用了一套精妙的 Grid/Cell 系统来搞定这件事。
二、Grid 与 Cell:艾泽拉斯的行政区划
2.1 三级网格体系
打开 GridDefines.h,你会发现一组常数定义------它们描述了整个空间划分体系:
| 常数 | 值 | 含义 |
|---|---|---|
SIZE_OF_GRIDS |
533.33... (1600/3) | 一个 Grid 的边长(码) |
MAX_NUMBER_OF_GRIDS |
64 | 每张地图每个维度的 Grid 数 |
MAX_NUMBER_OF_CELLS |
16 | 每个 Grid 每个维度的 Cell 数 |
| Map 总尺寸 | 533.33 × 64 ≈ 34,133 码 | 整张地图的边长 |
| Cell 尺寸 | 533.33 ÷ 16 ≈ 33.33 码 | 一个 Cell 的边长 |
换算成你熟悉的单位:
- 1 Grid ≈ 533 码 ≈ 暴风城贸易区到法师塔的距离
- 1 Cell ≈ 33 码 ≈ 一个施法距离(刚好大于大多数法术的射程上限)
整个空间层级长这样:
┌─────────────────────────────────────────────────────────┐
│ Map (34,133 × 34,133) │
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
│ │Grid │Grid │Grid │Grid │Grid │Grid │Grid │Grid │ │
│ │(0,0)│(1,0)│ │ │ │ │ │(63,0)│ │
│ ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ ┌──┼──┼──┼──┐ │ │ │ │ │
│ │ │ │ │C│C│C│C│ │ │ │ │ │ │
│ │ │ │ ├──┼──┼──┼──┤ │ │ │ │ │
│ │ │ │ │C│C│C│C│ │ │ │ │ │ 每个 Grid 内含 16×16 Cell│
│ │ │ │ ├──┼──┼──┼──┤ │ │ │ │ │
│ │ │ │ │C│C│C│C│ │ │ │ │ │ │
│ │ │ │ └──┼──┼──┼──┘ │ │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ │ │ │ │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ │
│ │(0,63)│ │ │ │ │ │ │(63,63)│ │
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
└─────────────────────────────────────────────────────────┘
2.2 Cell 的数据结构
打开 Cell.h,最有趣的是 data 字段。它的设计非常 C 风格,但你也得承认------很高效:
cpp
union {
struct {
unsigned grid_x : 8; // 第几个 Grid(X轴)
unsigned grid_y : 8; // 第几个 Grid(Y轴)
unsigned cell_x : 8; // Grid 内的第几个 Cell(X轴)
unsigned cell_y : 8; // Grid 内的第几个 Cell(Y轴)
} Part;
uint32 All; // 四个 8-bit 挤在一个 32-bit 整型
} data;
一个 uint32 就表达了(grid_x, grid_y, cell_x, cell_y)四个坐标。 位域压缩的精妙在于:Cell 的相等比较、哈希查找全部可以通过一次 uint32 比较完成。
辅助宏有:
cpp
#define CENTER_GRID_ID (MAX_NUMBER_OF_GRIDS / 2) // = 32
#define CENTER_GRID_OFFSET (SIZE_OF_GRIDS / 2) // ≈ 266.67 码
#define SIZE_OF_GRID_CELL (SIZE_OF_GRIDS / MAX_NUMBER_OF_CELLS) // ≈ 33.33 码
坐标到 Grid 的换算由 Acore::ComputeGridCoord(x, y) 完成------把一个世界坐标 (x, y) 映射到 GridCoord,后者是 CoordPair<MAX_NUMBER_OF_GRIDS> 的类型别名。
2.3 按需加载:不要一次性加载整个世界
如果启动时就把 64×64=4096 个 Grid 全部加载到内存,嗯......卡斯特罗都会跪下。更别说每个 Grid 还包含 16×16=256 个 Cell,这意味着整张地图需要 1,048,576 个 Cell 的容器。
AzerothCore 的策略是按需延迟加载(Lazy Loading) 。相关逻辑集中在 MapGridManager.h:
cpp
class MapGridManager {
void CreateGrid(uint16 x, uint16 y); // 创建 Grid 壳(分配容器)
bool LoadGrid(uint16 x, uint16 y); // 从数据库加载 Grid 数据
void UnloadGrid(uint16 x, uint16 y); // 卸载 Grid 数据
bool IsGridCreated(uint16 x, uint16 y) const; // 框架存在?
bool IsGridLoaded(uint16 x, uint16 y) const; // 数据加载了?
// 核心存储:64×64 的 unique_ptr 二维数组
std::unique_ptr<MapGridType> _mapGrid[MAX_NUMBER_OF_GRIDS][MAX_NUMBER_OF_GRIDS];
};
三个状态对理解这个系统至关重要:
| 状态 | 含义 | 触发条件 |
|---|---|---|
未创建 (!IsGridCreated) |
容器都没分配,内存为 0 | 默认初始状态 |
已创建未加载 (IsGridCreated && !IsGridLoaded) |
Grid 框架有了,但还没从 DB 读实体 | 玩家进入 Grid 邻接区域后预创建 |
已加载 (IsGridLoaded) |
DB 中的 Creature/GO 已读入内存,处于活动状态 | 玩家实际进入 Grid 后触发 |
策略很聪明:当你站在 Grid A 时,系统会预创建周围的 Grid(东北、西北、东南、西南 + 核心 5×5 区域),但只实际加载你所在的 Grid。 你往前走一步,系统在后面悄悄卸载,在远处悄悄预创建。
Grid 卸载条件 :如果 Grid 上没有任何玩家,且卸载定时器到期------CanUnload() 返回 true,Grid 进入回收流程。回收时,对象会被标记为移除列表,等待下一轮 Update 清理。
数据层方面,MapGrid 又包含了 GridCell 的实际容器:
cpp
// MapGrid.h 中,MapGrid 是一个模板类
template<class GRID_OBJECT_TYPES, class FAR_VISIBLE_OBJECT_TYPES>
class MapGrid {
uint16 _x, _y;
bool _objectDataLoaded; // 是否已从 DB 加载数据
std::shared_ptr<GridTerrainData> _terrainData; // 地形数据(副本之间共享)
// 16×16 的 cells 容器
std::array<std::array<std::unique_ptr<GridCellType>, MAX_NUMBER_OF_CELLS>, MAX_NUMBER_OF_CELLS> _cells;
};
注意 _terrainData 是 shared_ptr------副本地图之间共享父世界的地形数据,不重复加载。
每个 GridCell 内部则维护两个容器:
cpp
// GridCell.h
template<class GRID_OBJECT_TYPES, class FAR_VISIBLE_OBJECT_TYPES>
class GridCell {
TypeMapContainer<GRID_OBJECT_TYPES> _gridObjects; // 常规网格对象
TypeVectorContainer<FAR_VISIBLE_OBJECT_TYPES> _farVisibleObjects; // 远距离可见对象
};
为什么需要 _farVisibleObjects? 有些东西不管你在 Grid 的哪个角落都应该能看到------比如世界之树(Nordrassil)、暴风城的狮鹫飞行点。它们注册到 _farVisibleObjects,不管 Cell 坐标多远,都会被访问到。
三、Map 的谱系:从大陆到副本
3.1 继承链
Map.h 里面定义了清晰的谱系:
Map ← 基类,代表一片世界区域
├─ MapInstanced ← 桥接类,管理某张地图的所有副本实例
│ ├─ InstanceMap ← 五人本/团本实例
│ └─ BattlegroundMap ← 战场/竞技场实例
└─ (普通地图,非实例) ← 被 MapInstanced 替代
等一下------MapInstanced 继承了 Map?这意味着:
MapMgr里的i_maps集合对于副本地图只有一个MapInstanced对象(比如只有一个MapInstanced代表「所有影牙城堡实例」)- 这个
MapInstanced内部维护一个InstancedMaps = std::unordered_map<uint32, Map*>,以 InstanceId 为 key,存放每个实际副本的InstanceMap - 当
Update()循环来到某地图时,MapInstanced会迭代所有的 m_InstancedMaps,逐个调用它们的Update()
查源码确认(MapInstanced.h):
cpp
class MapInstanced : public Map {
using InstancedMaps = std::unordered_map<uint32, Map*>;
InstancedMaps m_InstancedMaps;
Map* CreateInstanceForPlayer(const uint32 mapId, Player* player);
Map* FindInstanceMap(uint32 instanceId) const;
bool DestroyInstance(InstancedMaps::iterator& itr);
};
这和现实生活中一个道理:影牙城堡是一个「地图 ID」= 329,而这个 ID 下面有无数个副本 ID,每个副本 ID 对应一个独立的 InstanceMap 实例。 你进副本就是 CreateInstanceForPlayer,打完退组无人后经过定时器 → DestroyInstance。
3.2 InstanceMap 与 Boss 战
InstanceMap 在 Map 基础上增加了副本特有的逻辑:
cpp
class InstanceMap : public Map {
void CreateInstanceScript(bool load, std::string data, uint32 completedEncounterMask);
bool Reset(uint8 method, GuidList* globalSkipList = nullptr);
InstanceScript* GetInstanceScript();
uint32 GetScriptId() const;
uint32 GetMaxPlayers() const;
private:
bool m_resetAfterUnload; // 无人后是否需要重置
bool m_unloadWhenEmpty; // 无人后是否需要卸载
InstanceScript* instance_data; // Boss 战状态、箱子开关控制
uint32 i_script_id; // 脚本 ID(对应数据库中的模板)
};
这里的关键是 InstanceScript ------它是串联 Boss 战逻辑的核心。CreateInstanceScript 加载数据库中的保存数据,通过 completedEncounterMask 确定哪些 Boss 已经死了、箱子还能不能开。不在副本(普通地图)中不需要 InstanceScript,所以基类 Map 没有。
3.3 MapMgr:总览全局
MapMgr(MapMgr.h)是管理所有地图的单例,#define sMapMgr MapMgr::instance() 让它在代码里随处可调。
MapMgr (Singleton)
├── i_maps: unordered_map<uint32, Map*> ← 所有地图 ID → Map 对象
│ ├── mapId=0 (东部王国) → MapInstanced
│ │ ├── InstanceId=1 → InstanceMap(死亡矿井)
│ │ └── InstanceId=2 → InstanceMap(死亡矿井·另一个组)
│ ├── mapId=1 (卡利姆多) → MapInstanced
│ ├── mapId=329 (影牙城堡) → MapInstanced
│ │ ├── InstanceId=123 → InstanceMap(你的队伍)
│ │ └── InstanceId=456 → InstanceMap(另一个队伍)
│ └── ...
├── m_updater: MapUpdater ← 多线程更新器
└── _instanceIds: vector<bool> ← 实例 ID 位图(分配追踪)
核心方法:
cpp
Map* FindBaseMap(uint32 mapId) const; // 找基类 Map(地图模板)
Map* FindMap(uint32 mapId, uint32 instanceId) const; // 找具体实例
Map* CreateBaseMap(uint32 mapId); // 创建基类 Map
Map* CreateMap(uint32 mapId, Player* player); // 为玩家创建/找到地图
Map::EnterState PlayerCannotEnter(...); // 检查能否进入
void Update(uint32); // 更新所有地图
创建逻辑的巧妙之处:CreateBaseMap 是惰性创建的------第一次请求时才 new MapInstanced。而 FindMap(mapId, instanceId) 则是先找到基类,再通过 MapInstanced::FindInstanceMap() 下钻到具体实例。
此外,MapMgr 还提供了 DoForAllMaps 和 DoForAllMapsWithMapId 两个模板方法,用于遍历所有地图或某一地图 ID 的所有实例------这在需要广播消息或全局操作时非常有用。
四、Cell 访问机制:「我附近有什么?」
空间分区把所有实体塞进了网格,但问题仍然在:一个法术释放了,我怎么找到它的目标?一个玩家移动了,我怎么通知附近所有可被看见的实体?
答案是一个 C++ 模板模式的经典应用:TypeContainerVisitor + Visit 模式。
4.1 入口:Cell::Visit
所有访问的入口是这个函数:
cpp
// Cell 的静态方法,以世界对象为中心
template<class T>
static void Cell::VisitObjects(WorldObject const* obj, T& visitor, float radius);
这个函数会做以下事情:
- 根据
obj的当前位置和radius,计算出需要访问的范围(哪些 Cell 落在圆内) - 为每个 Cell 执行
Visit(),传入一个TypeContainerVisitor TypeContainerVisitor会迭代 GridCell 内部的TypeMapContainer
当 radius 很大的时候,访问范围可能横跨多个 Grid------这时系统会调用 Map::Visit() 方法来做跨 Grid 访问。
4.2 Map::Visit 的实现
cpp
template<class T, class CONTAINER>
inline void Map::Visit(Cell const& cell, TypeContainerVisitor<T, CONTAINER>& visitor) {
uint32 const grid_x = cell.GridX();
uint32 const grid_y = cell.GridY();
// 如果 Grid 还没加载,跳过(这 Cell 可能没实体)
if (!IsGridLoaded(GridCoord(grid_x, grid_y)))
return;
// 委托给 MapGrid::VisitCell → GridCell::Visit
GetMapGrid(grid_x, grid_y)->VisitCell(cell.CellX(), cell.CellY(), visitor);
}
这套体系的核心思想是:能访问的只是已加载的 Grid。未加载的 Grid 里没有活跃实体,直接跳过。
4.3 TypeMapContainer:类型安全的多重容器
AzerothCore 里,网格对象不是所有塞进一个 vector<WorldObject*>。而是按类型分开放:
TypeMapContainer<GRID_OBJECT_TYPES>
内部实际上是多个容器的元组,每种类型一个容器
比如 GRID_OBJECT_TYPES 是 (Creature, GameObject, DynamicObject, Corpse, Pet)
那内部就有 5 个独立的 unordered_set
访问时,TypeContainerVisitor 会将其转化为针对特定类型的访问
比如 "访问所有 Creature" → 只走 Creature 的容器,不碰其他
这就避免了两个问题:
- 不需要 RTTI(运行时类型判断)------编译期就知道要遍历哪个容器
- 不需要 dynamic_cast 和 if-else 链------类型静态确定,访问内联展开
4.4 可见性范围
每个 Map 还有自己的可见范围:
cpp
float m_VisibleDistance; // 默认 533.33(一个 Grid 的长边)
void SetVisibilityRange(float range);
float GetVisibilityRange() const;
玩家在艾泽拉斯能看到约 533 码(一个 Grid 边长)的视野------这就是为什么你会看到远处山上的怪物轮廓,走近了才发现是只 40 级精英。在副本里,InstanceMap 可以覆盖这个范围。而战场里有时候整个地图都可见。
4.5 Far Visible & Zone Wide Visible
除了常规的 Cell 访问,还有两种特殊的可见系统:
-
Far Visible(
_farVisibleObjects) :被注册到 GridCell 的_farVisibleObjects容器中,即使距离很远也会被访问到。典型用例:海加尔山的世界之树。 -
Zone Wide Visible(
_zoneWideVisibleWorldObjectsMap):按区域(Zone)组织的集合,一个区域内的对象全区域可见。典型用例:副本里的 Boss 传送门、战场旗帜。
五、Map 更新循环:心跳
5.1 主循环入口
MapMgr::Update(uint32 diff) 是这个心跳引擎的起点。每 100ms 左右循环一次,分四步更新不同类型的底层地图指针:
cpp
void MapMgr::Update(uint32) {
// 分阶段更新以避免所有地图在同一帧进行大量更新
// [0] = 世界地图(大陆:东部王国、卡利姆多等)
// [1] = 战场/竞技场
// [2] = 副本/地城
// [3] = 总计时器(管理重置等)
}
这样设计是为了负载均衡------不同类型的地图不在同一帧全量更新。
5.2 Map::Update 内部
每个 Map::Update 做了大量事情。我整理了 Map.h 中所有接口的职责:
cpp
void Map::Update(const uint32 diff, const uint32 s_diff, bool thread = true) {
// 1. 更新所有玩家位置(PlayerRelocation → 触发 npc 的 AI 检测)
// 2. 移动列表中的生物和物体(MoveAllCreaturesInMoveList)
// 3. 移除队列中的对象(RemoveAllObjectsInRemoveList)
// 4. 处理延迟可见性(HandleDelayedVisibility)
// 5. 更新天气(UpdateWeather)
// 6. 处理事件队列(Events)
// 7. 更新过期尸体(UpdateExpiredCorpses)
// 8. 处理脚本调度(ScriptsProcess)
// 9. 发送对象更新包(SendObjectUpdates)
}
Map.h 的完整接口列表几乎覆盖了游戏逻辑的所有方面:
| 类别 | 核心方法 | 说明 |
|---|---|---|
| 实体管理 | AddToMap, RemoveFromMap, AddObjectToRemoveList |
对象增删改 |
| 网格管理 | LoadGrid, LoadAllGrids, UnloadGrid, UnloadAll |
Grid 生命周期 |
| 高度/地形 | GetHeight, GetMinHeight, IsInWater, IsUnderWater |
地形查询 |
| 碰撞检测 | isInLineOfSight, GetObjectHitPos, CanReachPositionAndGetValidCoords |
视野/路径检测 |
| 可见性 | SetVisibilityRange, GetVisibilityRange, InitVisibilityDistance |
视野范围 |
| 对象查找 | GetCreature, GetGameObject, GetCorpse, GetDynamicObject, GetPet |
按 GUID 查询 |
| 重生系统 | SaveCreatureRespawnTime, GetCreatureRespawnTime, LoadRespawnTimes |
重生调度 |
| Zone 系统 | GetAreaId, GetZoneId, GetZoneAndAreaId, UpdatePlayerZoneStats |
区域管理 |
| 天气/光照 | SetZoneWeather, SetZoneMusic, SetZoneOverrideLight |
环境特效 |
| 副本 | UpdateEncounterState, LogEncounterFinished, SendResetWarnings |
Boss 战状态 |
| 运输工具 | SendInitTransports, GetTransport, AddObjectToPendingUpdateList |
船/飞艇/电梯 |
| 动态树 | InsertGameObjectModel, RemoveGameObjectModel, Balance, GetGameObjectFloor |
动态碰撞 |
5.3 MapUpdater:线程池
早期 WoW 模拟器(MaNGOS 时代)是单线程更新所有地图的。AzerothCore 引入了线程池机制:
cpp
// MapUpdater.h
class MapUpdater {
ProducerConsumerQueue<UpdateRequest*> _queue; // 任务队列
std::atomic<int> pending_requests; // 等待计数
std::vector<std::thread> _workerThreads; // 工作线程池
};
逻辑流程:
MapMgr::Update()
│
├─ 对每个大陆地图:schedule_update(Map&, diff) → push 到队列
├─ 对每个副本实例:schedule_update(Map&, diff) → push 到队列
└─ wait() ← 等待所有线程完成
│
▼
WorkerThread() × N(N = 配置的线程数)
├─ pop 任务
├─ 执行 map->Update(diff)
└─ pending_requests--
线程数由配置文件 MapUpdateThreads 控制,根据服务器核心数合理设置(通常是 CPU 核心数的一半到全部)。副本地图的多线程更新是安全的------因为每个副本实例的 Map 对象之间没有任何共享状态。
六、重生系统:怪物是怎么回来的?
Map 的 Update 中还有一个容易被忽视的功能:重生(Respawn)。在数据库的世界里,creature 表有 spawntimesecs 字段,但游戏中还需要程序层面的管理。
Map 内部维护了两个重生时间字典:
cpp
std::unordered_map<ObjectGuid::LowType, time_t> _creatureRespawnTimes;
std::unordered_map<ObjectGuid::LowType, time_t> _goRespawnTimes;
当一个怪物死亡时:
ScheduleCreatureRespawn(guid, respawnTimer)写入重生时间到 DB- 同时内存中记录:
_creatureRespawnTimes[dbGuid] = now + respawnDelay - 每帧
UpdateNonPlayerObjects检查是否有到期重生 - 到期后根据
spawnMask、phaseMask重新从模板创建实体对象,AddToMap放回到原来的 Cell 中
如果玩家在怪死后立即跑出了 Grid,等重生时间到:Grid 已经被卸载了。没关系,重生时间已经持久化到 DB 的 creature_respawn 表 ,下次 Grid 加载时会通过 LoadRespawnTimes() 重新加载回来。
七、实例化体系的完整路径
让我们追踪一个玩家进入死亡矿井的完整路径:
-
执行
/enter或走进洞窟传输门 →Player::HandleExtraActionForItem触发进入副本 -
MapMgr::CreateMap(mapId=36 (死亡矿井), player)被调用- 先用
FindBaseMap(36)找到MapInstanced对象 MapInstanced::CreateInstanceForPlayer(36, player)被调用- 检查 InstanceSave,决定是开新实例还是重连已有实例
- 先用
-
副本创建(如果是第一次):
GenerateInstanceId()分配全局唯一 IDnew InstanceMap(36, instanceId, difficulty, parentMap)创建实际地图CreateInstanceScript(false, "", 0)创建 InstanceScript(没有任何 Boss 被击杀)- 默认可视范围 533 码(但因为是副本,实际都是室内,可能覆盖更小范围)
-
玩家进入 →
InstanceMap::AddPlayerToMap(player)Map::AddPlayerToMap基类通用逻辑- 把玩家放到
(grid_x, grid_y)对应的 Cell 中 - 触发
PlayerRelocation→ 通知周围 NPC(「影牙苦工侦测到一个活人」)
-
退组后无人
- 最后一个玩家退出 →
RemovePlayerFromMap→m_unloadWhenEmpty || m_resetAfterUnload检查 DestroyInstance→UnloadAll→DeleteRespawnTimes→ 从m_InstancedMaps移除- 内存被回收
- 最后一个玩家退出 →
八、总结
空间分区和地图管理解决了一个最朴素的问题:一台 2008 年的服务器不可能同时更新世界上所有的东西。
AzerothCore 的答案是多层分区 + 按需加载:
- 64×64 Grid + 16×16 Cell 将世界切分成可控的小块
- Grid 的创建/加载/卸载三状态确保只加载玩家附近的活跃区域
- Map → MapInstanced → InstanceMap/BattlegroundMap 三层继承实现了实例化模型
- TypeContainerVisitor + Cell::Visit 模式实现了类型安全的空间查询
- MapUpdater 线程池实现了副本地图的并行更新
- 重生时间持久化确保跨 Grid 卸载的重生状态恢复
如果说实体01(Object继承链)回答了「游戏里的每个东西是什么」,那实体02就回答了「这些东西在世界的哪个位置,以及系统怎么找到它们」。
从 34,133 码的大陆尺寸到 33 码的 Cell 粒度,空间分区的逻辑其实就一句话:把大问题切成小问题。小到服务器只需要关心你周围一块 533 码见方的世界,其他一切等你走过去再说。
本文基于 AzerothCore 3.3.5(liyunfan1223 维护分支)源码分析。源码路径:src/server/game/Maps/ 和 src/server/game/Grids/ 等目录。