13 客户端可见性与区块数据包
第 1 篇已经说过:客户端的区块数据完全来自服务端同步。这一篇只追踪一个更具体的问题:服务端什么时候认为 “这个玩家应该看到这个区块”,以及它会用哪一种数据包把变化发过去。
1 服务端权威,客户端只是快照
客户端渲染出来的地形不是本地权威世界,而是服务端发来的区块快照。服务端的 WorldChunk 才是权威数据源;客户端收到 ChunkDataS2CPacket 后,只是在自己的 ClientWorld 中重建一份可渲染、可查询的本地副本。
这也解释了 “看得见” 和 “在运算” 为什么不是同一件事。客户端渲染受观察距离(watchDistance,也就是玩家视距相关的区块观察范围)控制;服务端方块刻、实体刻受第 3 篇讲过的加载等级和模拟距离控制。玩家可能看得见远处地形,但那里的红石、随机刻或实体 AI 已经不再运行。
2 区块数据包的类型
服务端同步区块并不是 “一种大包走天下”。最重的一类是 ChunkDataS2CPacket,也就是区块数据包(S2C)。当一个区块新进入玩家视野时,ThreadedAnvilChunkStorage.sendWatchPackets() 会通过 sendChunkDataPackets() 发送它。这个包携带整区块的主要客户端可见数据:方块状态、生物群系、方块实体数据,以及由 ServerLightingProvider 填入的光照数据。客户端拿到它后,才能在本地建立对应的可渲染区块。
进入视野以后,绝大多数变化不需要再重发整个区块。单个方块变化通常使用 BlockUpdateS2CPacket,也就是方块更新包(S2C);同一个子区块内积累了多个方块变化时,ChunkHolder.flushUpdates() 会改用 ChunkDeltaUpdateS2CPacket,也就是区块增量更新包(S2C)。光照变化则通过 LightUpdateS2CPacket,也就是光照更新包(S2C),单独发送给处在观察距离边缘的玩家。多种包的分工本质上是带宽优化:第一次进入视野需要完整快照,之后的局部变化只发送差异。
[!NOTE]
sendChunkDataPackets()在发送ChunkDataS2CPacket后,还会刷新该区块内实体对该玩家的追踪状态,并补发拴绳、乘客等实体关系包。但这些属于实体同步系统,不是区块数据包本身。
3 何时发送区块数据
玩家加入、离开或移动时,TACS 会重新计算玩家观察哪些区块。加入和离开走 handlePlayerAddedOrRemoved();移动走 updatePosition()。两者都会围绕玩家新旧所在的 ChunkSectionPos 枚举候选区块,并用 isWithinDistance() 判断区块是否在当前 watchDistance 内。
当一个区块从 “不在视野内” 变成 “在视野内” 时,sendWatchPackets() 会取到对应 ChunkHolder,再取其 WorldChunk,然后发送 ChunkDataS2CPacket。当一个区块从 “在视野内” 变成 “不在视野内” 时,同一个方法只调用 player.sendUnloadChunkPacket(pos),告诉客户端丢弃该区块的本地副本。
方块变化走另一条路径。ChunkHolder.markForBlockUpdate() 不会立刻发包,而是把局部坐标打包成 short,放进 blockUpdatesBySection。每个 tick 后续刷新时,flushUpdates() 才把这些变化批量发给正在观察该区块的玩家。这样,同一 tick 内大量相邻方块变化可以合并成一次区块增量更新,而不是制造许多零散小包。
视距变化也会触发重算。setViewDistance() 先把新值限制在 2 到 32,再更新 ticketManager 的观察距离,然后遍历当前所有 ChunkHolder。对每个正在观察该区块的玩家,它分别计算旧距离和新距离下的 isWithinDistance() 结果;只有 “旧内新外” 或 “旧外新内” 这类边界变化才会调用 sendWatchPackets()。
4 watchDistance 的几何判定
watchDistance 的判定不是简单的切比雪夫距离。加载票传播常用 “横向或纵向最大偏移” 来形成正方形范围,而 ThreadedAnvilChunkStorage.isWithinDistance() 做的是另一套几何:先把 X/Z 方向的差值各缩小 1,再用一个近似欧几里得距离的表达式比较。
核心逻辑可以改写成:
先减去的 1 格让玩家所在区块周围有一圈更宽松的核心区域;随后 shortSide² + longSide² < distance² 让角落被削掉。因此,客户端观察范围更像一个圆角矩形,不是加载票传播那种纯正方形。这个差异很重要:同样叫 “距离”,watchDistance 决定的是客户端该不该保留区块快照,而 ticket 传播决定的是服务端该把区块推进到什么加载等级。
5 玩家区块观察管理器
TACS 持有一个 PlayerChunkWatchingManager,也就是玩家区块观察管理器。它记录每个区块当前被哪些玩家观察,并处理玩家观察中心移动、禁用观察、重新启用观察等状态。
这个管理器不直接生成区块数据包,但它决定 “谁应该收到”。ChunkHolder.flushUpdates() 通过 PlayersWatchingChunkProvider 查询观察者列表:光照更新查询观察距离边缘的玩家,方块更新查询所有正在观察该区块的玩家。换句话说,PlayerChunkWatchingManager 是整包、增量包、光照包最终投递目标的索引表。
6 小结
- 客户端区块是服务端同步出的快照;客户端渲染和服务端运算由不同距离系统控制。
ChunkDataS2CPacket用于新进入视野的整区块同步,BlockUpdateS2CPacket和ChunkDeltaUpdateS2CPacket用于方块状态变化,LightUpdateS2CPacket用于光照变化。- 玩家加入、离开、移动和视距变化都会触发观察范围重算;只有进入或离开观察范围的边界变化才会发送整包或卸载包。
watchDistance使用圆角矩形式的几何判定,不等同于加载票传播中的切比雪夫距离。PlayerChunkWatchingManager跟踪每个区块的观察者,是决定数据包发给哪些玩家的关键索引。
7 代码走读
7.1 sendWatchPackets:四种进入 / 离开情况
sendWatchPackets() 的参数中,oldWithinViewDistance 表示调用前该区块是否在玩家观察范围内,newWithinViewDistance 表示调用后是否在范围内:
这个方法其实只处理四种布尔组合:
注意 MutableObject<ChunkDataS2CPacket> 的作用:同一次枚举中,一个区块数据包可能要发给多个玩家。sendChunkDataPackets() 只有在 cachedDataPacket.getValue() == null 时才构造 new ChunkDataS2CPacket(chunk, this.lightingProvider, null, null),后续玩家复用同一个包对象,避免重复序列化整区块数据。
7.2 isWithinDistance:圆角矩形而非正方形
源码如下:
第一步 abs(delta) - 1 会把相邻方向的距离压到 0。也就是说,在玩家所在区块附近,判定对直线方向更宽容,不会因为区块坐标刚跨过边界就立即出现尖锐的视觉裁切。
第二步把较长轴再减 1,得到 l;较短轴直接作为 m。最后比较的是 m² + l² < distance²。当区块位于正东、正西、正南、正北方向时,短轴为 0,范围可以延伸得更远;当区块位于对角线方向时,两轴都会参与平方和,角落会更早被排除。
这就是为什么观察范围不是 (abs(dx) <= distance && abs(dz) <= distance) 那样的正方形。它保留了接近方形的主体,又削掉远角,让客户端视野边界更接近圆形视距,同时避免每次都计算真实浮点距离。
7.3 flushUpdates:从 blockUpdatesBySection 到批量发包
方块变化先进入 ChunkHolder.markForBlockUpdate():
这里有三个关键点。第一,只有 getWorldChunk() 已经可用时才记录更新;还没进入可 tick 的完整区块不会给客户端发局部变化。第二,数组下标是竖直子区块下标,同一子区块内的多个方块变化共享一个 ShortSet。第三,位置用第 1 篇讲过的 ChunkSectionPos.packLocal() 压成 short,只保存子区块内局部坐标。
刷新时,flushUpdates() 先处理光照:
这里的 getPlayersWatchingChunk(this.pos, true) 只取观察距离边缘的玩家。光照边界需要特别同步,因为边缘区块的光照会影响客户端渲染连续性;发送后两个 BitSet 立即清空,避免下次重复发送。
然后处理方块状态变化:
list2 使用 getPlayersWatchingChunk(this.pos, false),也就是所有正在观察该区块的玩家。每个非空子区块单独判断:如果 shortSet.size() == 1,发送 BlockUpdateS2CPacket;如果同一子区块内有多个位置变化,发送 ChunkDeltaUpdateS2CPacket。两种路径都会检查方块实体:单方块路径直接调用 tryUpdateBlockEntityAt(),增量路径通过 visitUpdates() 遍历每个变化位置。
这套设计把 “变化记录” 和 “网络发送” 拆开:方块变化发生时只做轻量记录,真正发包时再按观察者、子区块和变化数量选择最合适的数据包。
8 参考
net.minecraft.server.world.ThreadedAnvilChunkStoragenet.minecraft.server.world.ChunkHoldernet.minecraft.server.world.PlayerChunkWatchingManagernet.minecraft.network.packet.s2c.play.ChunkDataS2CPacketnet.minecraft.network.packet.s2c.play.ChunkDeltaUpdateS2CPacketnet.minecraft.network.packet.s2c.play.LightUpdateS2CPacketnet.minecraft.network.packet.s2c.play.BlockUpdateS2CPacket
