09 随机刻与方块实体刻
第 3 篇已经提到:区块达到 方块运算级别(BLOCK_TICKING)后,服务端允许三种方块运算——计划刻(Scheduled Tick)、随机刻(Random Tick)和方块实体刻(Block Entity Tick)。计划刻的细节请见 MicroTiming:计划刻与计划刻元件;本文只补上另外两块:随机刻和方块实体刻怎样依赖区块运行级别。
1 随机刻
随机刻(Random Tick)是每 gt 对区块内若干随机位置进行抽样,然后让被抽中的方块或流体执行自身随机逻辑的机制。它不是计划刻:计划刻有明确的触发时间、位置、类型和优先级,而随机刻没有预约队列,也不保证某个方块一定会在某一 gt 被执行。一个作物方块长时间没有被抽中是正常的;一个区块内同一位置在相邻 gt 连续被抽中也同样可能。
随机刻首先依赖第 1 篇介绍过的 可随机刻方块计数(randomTickableBlockCount)。每个子区块(Chunk Section)在写入方块状态时增量维护这个计数;当计数为 0 时,ChunkSection.hasRandomTicks() 直接返回 false,ServerWorld.tickChunk() 就不会在这个 16×16×16 区域内抽样。这个设计的意义不是让随机刻更 “随机”,而是让大量没有随机刻方块的石头、空气、建筑材料子区块被 O (1) 跳过。
抽样次数由随机刻速率(randomTickSpeed,游戏规则 / Game Rule)控制。服务端对每个可随机刻的子区块执行 randomTickSpeed 次抽样;默认值为 3,设置为 0 时完全跳过随机刻循环。注意这个游戏规则控制的是 “每个可随机刻子区块每 gt 抽几次坐标”,不是 “每个作物每 gt tick 几次”。因此,把 randomTickSpeed 调高会增加被抽样的机会,但不会把随机刻变成确定的周期刻。
需要随机刻的通常是 “持续处在世界中、偶尔自发变化” 的方块或流体,例如作物生长、草方块蔓延、树叶腐烂、菌类扩散、火焰变化、冰雪相关变化,以及部分流体逻辑。它们的共同点是:不需要在某个精确 gt 执行,却需要在区块保持方块运算资格时逐步推进。
从宏观时序看,随机刻和计划刻都属于服务端每 gt 的方块运算阶段(常称 TT 阶段),也都依赖区块达到 BLOCK_TICKING。但两者机制完全不同:计划刻先由 WorldTickScheduler 在世界刻推进时取出到期任务,再调用 scheduledTick();随机刻则由区块 tick 遍历子区块、抽样坐标,再调用 randomTick() 或流体的随机刻方法。研究计划刻顺序时,不应把随机刻塞进计划刻队列;研究作物、草方块、树叶时,也不应假设它们拥有一个隐藏的计划刻。
2 方块实体刻
方块实体(Block Entity)用来保存或运算 BlockState 难以表达的复杂数据。BlockMechanics 第 1 篇已经给出分工:方块状态(Block State)适合保存取值有限、需要快速查表的信息;方块实体适合保存箱子物品、漏斗冷却、信标效果、告示牌文字等复杂数据。并不是所有方块实体都每 gt 运算,但会运算的方块实体需要进入方块实体刻。
方块实体刻的入口不是 “遍历区块里所有方块实体然后猜谁要 tick”,而是由方块实体 ticker(Block Entity Ticker)注册出来的。WorldChunk.updateTicker() 会读取方块实体缓存的 BlockState,通过 blockState.getBlockEntityTicker(world, type) 询问这个状态是否提供方块实体刻调用器(BlockEntityTickInvoker)背后的 ticker。若没有 ticker,就移除旧调用器;若有 ticker,就把它包装成 DirectBlockEntityTickInvoker,再通过 World.addBlockEntityTicker() 加入世界级列表。
这里区块字段会直接影响 ticker 是否真正执行。WorldChunk 持有 levelTypeProvider,可以随时查询当前 ChunkLevelType;包装后的调用器在每次 tick() 前都会检查 WorldChunk.canTickBlockEntity(pos)。在服务端,这个检查要求方块位置在世界边界内、区块仍已加载,并且 getLevelType().isAfter(ChunkLevelType.BLOCK_TICKING)。也就是说,ticker 可以已经在列表里,但区块降到 FULL 后,调用器会在执行前被挡住。
World.addBlockEntityTicker() 负责把调用器放入世界的 blockEntityTickers 列表;如果此时正在遍历方块实体,就先放入 pendingBlockEntityTickers,等下一轮合并,避免遍历中修改列表。之后 World.tickBlockEntities() 每 gt 遍历这些调用器:被移除的调用器从列表删除,仍可 tick 的位置调用 tick()。真正的行为仍由方块实体自己的 ticker 决定。
不同方块实体的内部周期差异很大。熔炉、移动活塞这类方块实体通常每 gt 推进状态;漏斗每 gt 进入 ticker,但用 transferCooldown 形成常见的 8gt 传输节奏;信标则在自己的逻辑中按 80gt 左右的周期更新效果;告示牌这类方块实体主要保存数据,不一定提供服务端 ticker。区块管理系统只决定 “这个调用器有没有资格被执行”,不替每种方块实体规定业务周期。
当区块降级或卸载时,方块实体刻会停止。降到 FULL 或更低时,canTickBlockEntity() 不再通过,调用器即使还留在世界列表中也不会执行实际 ticker;方块实体被移除时,removeBlockEntityTicker() 会把包装调用器替换成一个已移除的空调用器,下一次 World.tickBlockEntities() 遍历时将它清出列表;区块卸载时,WorldChunk.clear() 会标记所有方块实体已移除,并清空区块自己的 ticker 映射。
3 如何与 BLOCK_TICKING 衔接
BLOCK_TICKING 不是一个单独的 “红石开关”,而是一套 ticking 视图。第 7 篇讲过,ChunkHolder.tickingFuture 完成后,区块才进入可进行方块运算的视图;这个视图向上支撑计划刻、随机刻和方块实体刻三项运算。
三项运算的依赖方式略有不同:计划刻依赖 WorldTickScheduler 判断目标区块的 tick future 是否就绪;随机刻依赖 ServerChunkManager 只把达到方块运算资格的 WorldChunk 交给 ServerWorld.tickChunk();方块实体刻则在世界级列表遍历时,通过 shouldTickBlockPos() 和 WorldChunk.canTickBlockEntity() 再检查一次区块是否仍处于 BLOCK_TICKING 或更高。共同点是:只要区块退回 FULL,它仍可能在内存中可读写,但这些主动方块运算都应该停止。
4 常见误区
5 小结
- 随机刻是每 gt 的坐标抽样机制,不是计划刻队列的一部分。
randomTickableBlockCount让ServerWorld.tickChunk()跳过没有随机刻需求的子区块。randomTickSpeed控制每个可随机刻子区块的抽样次数,而不是单个方块的固定周期。- 方块实体 ticker 由
WorldChunk.updateTicker()根据当前BlockState注册或移除。 World.tickBlockEntities()遍历世界级调用器列表,但实际执行前仍检查方块位置是否允许 tick。- 计划刻、随机刻和方块实体刻都依赖
BLOCK_TICKING视图;区块退回FULL后,数据可在内存中存在,但主动方块运算停止。
6 代码走读
6.1 ServerWorld.tickChunk():随机刻部分
ServerWorld.tickChunk(WorldChunk chunk, int randomTickSpeed) 处理一个已被选中参与区块 tick 的 WorldChunk。天气、雷暴、冰雪等逻辑之后,随机刻主体位于 tickBlocks profiler 段:
这段代码有三个重点。
第一,randomTickSpeed <= 0 时整个随机刻循环不进入,所以 gamerule 为 0 会关掉随机刻。
第二,外层遍历的是区块的 ChunkSection[],不是 16×16× 高度的全部方块。chunkSection.hasRandomTicks() 正是第 1 篇介绍的计数器筛选:如果该子区块没有任何方块或流体声明随机刻需求,就不会抽样。
第三,抽样先取出被抽中的 BlockState,再分别检查方块状态和流体状态是否真的 hasRandomTicks()。这一步说明 “子区块可随机刻” 只是粗筛,具体坐标抽中空气、石头或其他不随机刻方块时,本次抽样不会产生方块随机刻行为。
6.2 World.tickBlockEntities():列表遍历与执行
World 维护两个列表:blockEntityTickers 保存当前可遍历的方块实体刻调用器,pendingBlockEntityTickers 暂存遍历过程中新增的调用器。
shouldTickBlockPos(pos) 会把方块坐标转成区块坐标,并调用 shouldTickBlocksInChunk()。在 ServerWorld 中,它最终询问 ticket manager 的 shouldTickBlocks(chunkPos)。这使方块实体刻和计划刻、随机刻一样,绑定在 BLOCK_TICKING 级别上。
但这里还有第二道门:BlockEntityTickInvoker.tick() 在 WorldChunk.DirectBlockEntityTickInvoker 中实现,它会再次调用 WorldChunk.canTickBlockEntity(pos)。这一步检查世界边界、区块是否已加载,以及 getLevelType().isAfter(ChunkLevelType.BLOCK_TICKING)。两层检查让世界级列表即使短暂保留了某个调用器,也不会在错误级别下执行实际方块实体逻辑。
6.3 WorldChunk.updateTicker():注册、替换与移除
方块状态改变、方块实体加载、区块进入世界时,WorldChunk 会更新对应方块实体的 ticker。最核心的方法是 updateTicker():
BlockState 是注册判断的源头:同一个方块实体类型,在不同方块状态下可能得到不同 ticker,也可能得不到 ticker。若得不到 ticker,旧调用器会被替换为空调用器并等待世界列表清理。
如果已有包装调用器,updateTicker() 不会向世界列表重复添加新对象,而是替换包装器内部的 wrapped。这样可以保持世界级列表的对象身份稳定,同时让实际执行逻辑更新为新状态对应的 ticker。
如果还没有包装调用器,并且 canTickBlockEntities() 为 true,WorldChunk 创建 WrappedBlockEntityTickInvoker 并交给 world.addBlockEntityTicker()。canTickBlockEntities() 主要检查区块是否已经 loadedToWorld(客户端另有路径),这解释了为什么加载中的区块即使读出了方块实体数据,也不会立刻开始方块实体刻:只有区块进入世界运行视图后,ticker 才会挂到世界列表上。
方块状态改变时还有一条重要路径:
这就是 "方块状态影响方块实体 ticker" 的具体位置:状态写入后,如果这个状态需要方块实体,区块要么创建新的方块实体并注册 ticker,要么更新已有方块实体的 cachedState 并重新计算 ticker。区块降级时,canTickBlockEntity() 会挡住执行;方块实体移除或区块清空时,包装调用器会被标记为已移除,最终从 World.tickBlockEntities() 的列表中消失。
7 附:方块实体 HashMap 与扩容重排
7.1 数据结构真相
Chunk.blockEntities 存放着区块内所有存活方块实体的 BlockPos → BlockEntity 映射。查阅 1.20.1 Yarn 源码(Chunk.java 第 83 行):
这是一个标准的 Java HashMap<BlockPos, BlockEntity>,而非某些社区印象中的 Long2ObjectOpenHashMap。初始容量 16,加载因子 0.75。
BlockPos(继承自 Vec3i)的哈希码计算(Vec3i.java 第 68–70 行):
展开即 x + 31·y + 961·z,与社区常用公式 x + 31·(y + 31·z) 完全等价。结果在 Java 32-bit int 上溢出。
7.2 区块重加载时的扩容场景
Java HashMap 在元素数量超过 floor(capacity × 0.75) 时触发 resize,容量加倍。blockEntities 的扩容阈值序列:
正常游戏中大多数区块只有个位数方块实体,很少达到 12 个。只有红石机械(漏斗阵列、活塞链)或刷怪塔(大量 skull/beacon 等 TE)密集的区块才会触发热阈值。
7.3 扩容如何改变顺序
HashMap resize 后,每个 entry 的桶索引重新计算:
oldIdx = (capacity - 1) & hash
newIdx = (capacity × 2 - 1) & hash
设扩容前容量为 C,分辨位 bit = C。只有 hash & bit == 1 的 entry 会从 oldIdx 移到 oldIdx + C(高半区),其余 entry 留在 oldIdx(低半区)。结果:在两个容量层级下,桶的相对顺序可能发生翻转。
整个 "扩容改变迭代顺序" 的因果链在源码中有三条路径:
7.3.1 路径 A:NBT 写顺序(最先受影响的)
ChunkSerializer.serialize()(第 355–360 行)遍历 getBlockEntityPositions() 来写 NBT 列表:
getBlockEntityPositions()(Chunk.java 第 153–157 行)创建一个新 HashSet,迭代 blockEntities.keySet():
当 blockEntities 扩容后重新建立,keySet().iterator() 的遍历顺序就不同了。这个差异会固化到 NBT 文件中,因为 block_entities NBT 列表的顺序直接来自这次遍历。
7.3.2 路径 B:NBT 读顺序(影响 ticker 注册顺序)
ChunkSerializer.getEntityLoadingCallback()(第 414–427 行)按 NBT 列表的存储顺序逐个读取 TE,并依次调用 chunk.setBlockEntity(blockEntity):
setBlockEntity()(第 374–383 行)做 blockEntities.put(pos.toImmutable(), blockEntity) 往 HashMap 放数据,紧接着 addBlockEntity() 中的 updateTicker() 调用 world.addBlockEntityTicker(),将 ticker 追加到世界级 ArrayList 末尾。
7.3.3 路径 C:世界级 tick 顺序(最终效应)
每游戏刻,World.tickBlockEntities()(第 500–522 行)按 ArrayList 的插入顺序执行 ticker:
ArrayList 的插入顺序完全由「哪次 addBlockEntityTicker() 先执行」决定。而这个顺序又来源于 NBT 列表的存储顺序——即前一次保存时 getBlockEntityPositions() 的迭代顺序。因此当 blockEntities 因扩容在保存时改变了遍历顺序,连锁影响到下一次加载后的 tick 执行顺序。
7.4 实际影响
对多数玩家而言无感。原因:
- 大多数区块的 TE 数量远低于 12,从未触发 resize
- 即使触发了,影响范围仅限于该区块内的 ticker 相对顺序
- 不同区块之间、方块实体的 tick 顺序由区块执行交错(chunk tick order)和每个 ticker 的独立位置共同决定,扩容只在特定、脆弱的时序依赖场景中暴露
真正受影响的场景:
- 红石粉更新顺序(历史原因):1.13 之前红石粉也是 TileEntity,后续版本虽已移除,但社区对 "重加载导致 TE 顺序翻转" 的认知延续至今
- 漏斗顺序与物品分发:漏斗的
BlockEntityTicker执行顺序决定物品流向,在漏斗链中 TE 顺序翻转可能导致物品路由变化 - 活塞时序链:多个活塞在同一个 gt 内依次执行,排序变化可能改变整体行为
7.5 工具:查找扩容翻转坐标对
社区开源工具 te_swap_calculator.py 基于 Vec3i.hashCode() 公式和 Java HashMap 扩容规律,可计算给定区块和 TE 数量下,扩容后顺序翻转的坐标对:
# 在 chunk(0, 0) 中,查找 12 个 TE 扩容到 32 容量时的翻转对
python te_swap_calculator.py 0 0 -k 12 -n 50
筛选条件:对于 BLOCK_TICKING 级别的区块,只有落在 canTickBlockEntity() 范围内的 TE 排序变化才实际影响 tick 顺序。该工具主要用于红石机械调试和顺序敏感型设计的参考。
8 参考
- Discovering Minecraft - 区块管理系统中的基本概念(CC0 协议)
- Yarn Mapping
1.20.1+build.10 net.minecraft.server.world.ServerWorldnet.minecraft.world.Worldnet.minecraft.world.chunk.WorldChunknet.minecraft.world.chunk.ChunkSection
