10 实体加载与实体追踪
前几章讨论的是区块本身:ChunkStatus 决定生成进度,ChunkLevelType 决定运行资格。但实体不是简单地塞在 WorldChunk 里跟着方块一起运算。Minecraft 对实体采用了另一套专门的管理系统:方块数据回答 “这个坐标是什么方块”,实体系统回答 “这个空间里有哪些会移动、会同步、会保存的对象”。
1 为什么实体和方块要分开管
方块存储在区块的 ChunkSection[] 中,每个 ChunkSection 是一段 16×16×16 的方块状态数组;实体则存储在 ServerEntityManager 中,并由实体管理器按 chunk / section 建立索引。也就是说,方块和实体是两套独立的数据结构、两套独立的加载/卸载机制:World.getBlockState() 只能从区块的方块数组里取 BlockState,不可能 “顺便” 取到实体;同样,实体查询走的是 EntityLookup,不会为了找实体而触发新的区块加载。
2 ServerEntityManager 的职责
ServerEntityManager 是服务端实体管理器。它管理的不是 “某一个实体当前在哪”,而是一个按 chunk / section 分组的实体存储与索引:SectionedEntityCache 负责把实体放进对应的 EntityTrackingSection,EntityIndex 负责 UUID / id 等快速查找,trackingStatuses 记录每个 chunk 当前对应的 EntityTrackingStatus。当实体移动到另一个 section 时,实体自己的 EntityChangeListener 会回调管理器,把它从旧 section 移到新 section,并根据新旧追踪状态决定是否开始或停止追踪、开始或停止 tick。
加载和卸载也由这里统筹。WorldChunk.loadEntities() 会执行区块构造时保存下来的 EntityLoader,最终把从 NBT 读出的实体流交给 ServerWorld.addEntities() / ServerEntityManager.loadEntities(),挂载进实体管理器。卸载时,ServerEntityManager.unload(ChunkPos) 会先调用 trySave() 把这个 chunk 的可保存实体写入 entities 区域文件,再把这些实体标记为 UNLOADED_TO_CHUNK 并从内存管理状态里移除。实体是否对客户端可见、是否参与 tick,则由 EntityTrackingStatus 表示:HIDDEN 不追踪不 tick,TRACKED 可同步但不 tick,TICKING 既同步也 tick。
3 EntityTracker 与玩家同步
实体 “在服务器里存在” 和 “某个玩家客户端能看见它” 也不是同一件事。TACS(ThreadedAnvilChunkStorage)内部维护了 entityTrackers:一个 Int2ObjectMap<EntityTracker>,键是实体的网络 id,值是该实体对应的实体追踪器。ServerEntityManager 在实体进入可追踪状态时调用 EntityHandler.startTracking(),再由 ServerWorld.ServerEntityHandler 把实体交给 TACS 的 loadEntity(),创建 EntityTracker。
每个 EntityTracker 持有一个 EntityTrackerEntry,记录实体上一次同步给客户端的状态。EntityTracker.updateTrackedStatus(player) 会比较玩家和实体的水平距离:距离不超过 min(实体类型追踪距离, 视距 * 16),并且该实体允许被该玩家观察时,就把玩家的网络连接加入监听集合并发送 spawn 包;反之则移除监听并发送 remove 包。这里的追踪范围受 “实体类型自身的最大追踪距离” 和 “服务器视距” 共同限制,而实体是否实际运算还要看模拟距离与 ENTITY_TICKING,两者相关但不是同一个开关。
4 ENTITY_TICKING 的前置条件
第 7 篇已经看到,makeChunkEntitiesTickable() 不是只等中心区块自己达到 FULL,而是调用 getRegion(holder, 2, distance -> ChunkStatus.FULL),要求以中心区块为核心的 5×5 范围全部达到 FULL。这比 makeChunkTickable() 的 3×3 更严格:方块刻只需要较近的邻域,而实体移动、碰撞、寻路和刷怪判定更容易跨越区块边界。最终的等级阈值仍然来自 ChunkLevels.shouldTickEntities(level):只有 level <= 31 的区块才具备实体运算资格。
5 实体查询不触发区块加载
World.getEntitiesByClass() / World.getEntitiesByType() 的底层是 getEntityLookup().forEachIntersects(...):它只在当前实体管理器已经持有的实体索引里查找相交实体,不会像区块读取那样去请求 ChunkStatus.FULL,也不会新增加载票。这正好呼应第 5 篇的结论:实体查询和方块查询是两套机制。方块查询问的是 “某个 chunk 的方块数组是否可访问”,实体查询问的是 “当前已加载、已挂载的实体索引里有没有对象”。
6 小结
- 方块数据在
ChunkSection[]中,实体数据在ServerEntityManager中;两者独立存储、独立加载、独立卸载。 ServerEntityManager负责实体的 chunk / section 分组、加载保存、追踪状态切换,以及 start/stop tracking 和 start/stop ticking 的生命周期回调。EntityTracker负责网络同步:为每个实体维护附近玩家集合,并按距离发送 spawn / update / remove 包。ENTITY_TICKING需要 level ≤ 31,并且makeChunkEntitiesTickable()要求 5×5 的FULL区块上下文。- 实体查询只查已经加载到实体管理器的实体,不会触发新区块加载。
7 代码走读
7.1 ServerEntityManager.trySave() 与 unload ()
ServerEntityManager 的保存逻辑从 trySave(long chunkPos, Consumer<T> action) 开始:
这里有三个状态需要分清:PENDING 表示该 chunk 的实体数据还在异步读取中,此时不能保存;FRESH 表示管理器还没有读过这个 chunk 的实体数据,如果贸然写入会覆盖磁盘上的旧实体,所以它会先 scheduleRead() 并返回失败;LOADED 表示实体数据已经读入或确认为空,可以安全写回。保存对象来自 cache.getTrackingSections(chunkPos),也就是这个 chunk 下所有 section 内 shouldSave() 的实体,而不是从 WorldChunk 的方块数据里取。
unload(long chunkPos) 在 trySave() 成功后才真正移除管理状态:
注意这里的 action 会对实体及其乘客链调用 unload(EntityLike),把它们标记为 UNLOADED_TO_CHUNK,并清除变更监听器。也就是说,卸载实体 chunk 的核心顺序是:先确认能保存,再写入实体区域文件,再解除实体与服务端实体管理器之间的生命周期联系。
7.2 EntityTracker.updateTrackedStatus() 调用链
实体追踪器的创建入口在 TACS 的 loadEntity():
这段代码说明 entityTrackers 是按实体网络 id 建立的映射。实体进入追踪系统时,TACS 立即对当前所有玩家执行一次 updateTrackedStatus(),决定哪些玩家应该收到该实体的 spawn。
运行过程中,tickEntityMovement() 会继续维护这个关系:
这里有两类更新:实体自己跨 section 时,重新判断所有玩家是否还该看见它;玩家跨 section 时,把该玩家加入 list,再让所有实体追踪器只针对这些移动过的玩家重算一次。entityTracker.entry.tick() 则负责常规的实体移动、旋转、速度等 update 包发送;它只在实体跨 section,或实体所在 chunk 仍允许实体 tick 时执行。
真正的可见性判断在 EntityTracker.updateTrackedStatus(ServerPlayerEntity player):
listeners 是 “正在看见这个实体的玩家连接集合”。玩家进入范围时,startTracking(player) 会发送实体生成与初始状态;玩家离开范围时,stopTracking(player) 会发送移除。这里判断的是 XZ 平面距离,并把实体类型最大追踪距离与服务器视距取较小值,因此 “客户端能看见多远” 不会超过视距,也不会超过该实体类型自己的追踪上限。
7.3 WorldChunk.loadEntities() 挂载流程
实体从磁盘数据进入实体管理器,中间经过 WorldChunk 的一个延迟挂载点。TACS 在 convertToFullChunk() 中把 ProtoChunk 转成 WorldChunk:
构造 WorldChunk 时传入的 EntityLoader 并不会立刻展开成实体列表,而是暂存在 WorldChunk.entityLoader 字段里。随后 worldChunk.loadEntities() 执行这个 loader:
这个 loader 最终调用 addEntitiesFromNbt():
ServerWorld.addEntities() 再把实体流交给 ServerEntityManager.loadEntities()。因此,WorldChunk 在这里更像一个 “触发挂载的中转站”:它持有从 chunk NBT 里来的实体 NBT,但实体真正进入运行、查询、保存和追踪系统,是在 ServerEntityManager 中完成的。与之对应,ServerWorld.unloadEntities(WorldChunk chunk) 只清理 WorldChunk 上的方块实体与 tick scheduler:
这里的方法名容易误导:它并不直接把普通实体从 WorldChunk 中取出来卸载,因为普通实体本来就不存放在 WorldChunk 的方块数组里;真正的实体保存与卸载仍由 ServerEntityManager 的 pendingUnloads、trySave() 和 unload() 完成。
8 参考
net.minecraft.server.world.ServerEntityManagernet.minecraft.server.world.ThreadedAnvilChunkStoragenet.minecraft.world.chunk.WorldChunknet.minecraft.server.world.ServerWorldnet.minecraft.world.entity.EntityTrackingStatus
