自动化工厂流水线(Automation / Factory)游戏策划专题高级tech8 / 21 已发布

大型工厂的模拟架构与 tick

确定性 tick · 实体分区 · 并行管线 · 存档可复现

· 15 分钟阅读
大型工厂的模拟架构与 tick:海量实体的心跳与呼吸 Simulation Architecture & Tick — 自动化工厂流水线(Automation / Factory)游戏策划专题

大型工厂的模拟架构与 tick:海量实体的心跳与呼吸 Simulation Architecture & Tick

为什么 tick 是大型工厂的第一块基石

你玩过那种到中后期就变成幻灯片的大工厂游戏吗?传送带上的矿石像便秘一样断断续续,机械臂举在半空思考人生,你放大拉近想看看产线哪里堵了,结果画面一卡,等你回过神来,整条产线已经堆成了一团乱麻。这时候你心里想的不是‘这游戏真有深度’,而是‘这破游戏到底在算什么算得这么慢’。

说句实话,大部分玩家根本不会意识到 tick 的存在,他们只会在帧率掉到 20 以下时骂骂咧咧地关掉游戏。但作为策划,你必须明白:帧率只是表象,tick 才是内里。工厂游戏和《使命召唤》的本质区别在于,它的大部分玩法逻辑——传送带上的物品移动、机械臂的抓取、炉子的冶炼周期——不需要每帧都更新,但它们又必须在某个确定的时间点被精确地结算。这就是一个节奏问题:你该让这个世界以什么频率呼吸,才能既显得生动,又不至于让 CPU 烧起来。

你也许会觉得:‘不就是设个定时器嘛,每 0.1 秒更新一次不就完了?’——要是真这么简单,就不会有《异星工厂》那套备受赞誉的优化神话了。这篇我们把 tick 拆开揉碎:它是怎么分层的、怎么分区的、怎么并行的,还有最容易被忽略的——怎么做到存档可复现。这套东西,是你工厂游戏‘活得起来’的地基。

第一层:tick 频率分层——世界不是匀速呼吸的

工业游戏世界的第一个秘密在于:它不像现实世界那样 60 帧地平等对待万物。在《异星工厂》里,渲染跑在 60 帧,但游戏逻辑的更新频率是每 tick 一次,也就是每秒 60 次(60 UPS)。别急着说‘这不就是每帧更新吗’——关键在于:不是所有东西都需要每 tick 都动。传送带上的物品位置其实是在做离散的跳跃,而不是连续滑动;机械臂的旋转动画可以插值,但它的逻辑状态只在抓取或放置的那个 tick 翻转。这意味着,你的 CPU 每秒钟要处理的实体更新次数是固定的,不会因为玩家视角的拉近拉远而改变——这保证了确定性。

但《异星工厂》的聪明之处在于,它不只设一个全局 tick。它把世界动态分成了“慢速系统”和“快速系统”两层。举个例子:箱子里物品的堆叠计数、管道里流体的压力平衡、火车网络的路径规划,这些都不需要每 tick 精确同步,它们可以在每 2-5 tick 才完整结算一次,中间用差值混淆视听。你看着管道里的液体流动得很平滑,其实它只是在几个离散的点之间做插值。

系统模块推荐 tick 频率更新对象代价与收益
渲染60 fps(可变动画插值)一切可见对象高视觉保真,GPU 并行,不影响逻辑
快速逻辑30-60 tick/s传送带物品移动、机械臂抓取脚本、击杀碰撞检测高频交互,保证手感爽快,CPU 开销大
慢速逻辑10-20 tick/s流体压力、电力网络分配、污染扩散、机器人充电分配降低计算量,可用差值动画弥补视觉断点
极慢逻辑1-4 tick/s敌对虫群扩张、贸易站刷新、天气事件低频不敏感,变化可接受,省出大量 CPU

这个分层思路在《放逐之城》(Banished)里也体现得很极致。百姓的“职业计算”(谁在采石场、谁在伐木场)以及物品的需求分配,不需要每帧都精确到小数点后三位。Banished 用的是一个月度 tick 来结算人口成长和食物消耗,日间只是做渲染和寻路。你得让玩家觉得这个世界是活的——每个月一号他们看着仓库数字跳一下,这套节奏感就成立了。

底层逻辑是这样的:玩家对空间的感知是连续的,但他们的百分比条未必需要连续跳动。把 t окли 吐到不同频率,你把玩家的手感留住了,把 CPU 的开销减下来了。你以为这很难吗?其实只是个基础的分层调度,难点在于——你分层之后,两个不同频率系统之间的状态怎样对齐?这就是下面要讲的分区问题了。

第二层:实体分区——世界是分块的,不是真空大平板

你有一个 500×500 格的工厂,里面有十万个传送带板块、五千个机械臂、两千个箱子。如果每个 tick 都把整个地图上的所有实体从头到尾扫一遍,你的计算量是 O(n) 级别的——n 够大时,CPU 就冒烟了。《异星工厂》的解法很直接:把世界分成边长 32 格的正方块区块。这听起来简单,但妙处在于,系统只会对“激活的区块”进行更新。如果一个区块里没有玩家,也没有正在变化的动态实体——比如一条完全静止的传送带,上面堆满了东西堵死了,那么它可以被标记为‘睡眠’,直到它在tinge 的某一次更新里被物理触碰或红石信号唤醒,才重新进入计算队列。

这就跟你家里装灯一样:谁也不会在没人房间时把每盏灯都打开。你只会在有人进去的时候,才点亮那个房间的灯。世界那么大,但‘活着的’区域永远只占一小片。但实现的重点在于‘谁负责唤醒’这件事——你不可能在每个实体旁边都放一个哨兵。

分区策略激活条件休眠条件经典游戏案例
固定区块(32×32 格)玩家进入、信号触发、实体新建无玩家、无动态实体、所有缓冲满《异星工厂》——区块内实体若全部静止,则整体睡眠
基于负载的动态分区该区域活跃实体数量超过阈值活跃实体数低于阈值且持续 N tick《RimWorld》——根据整块区域的工作负载动态调整模拟精度
水流/热扩散型分区流体压力差超过触发值压力平衡后进入准静态模拟《缺氧》——流体在区块间只传递接触面压力

表面上看,这只是个很蠢的做法——但妙处在于,“实体静止”这件事是玩家根本察觉不到的。当你的传送带被堵死时,玩家只会看到东西卡住了,但他们绝不会意识到‘这条坏掉的传送带占用的 CPU 已经降到了零’。这就是反向利用玩家预期:把‘故障’变成‘性能优化’的机会。

然而有个资深策划必须明白的陷阱——如果你把区块的休眠条件设得太激进(例如一静止就立刻睡眠),那么当一个爆炸事件在区块边缘发生时,唤醒逻辑会疯狂地连锁传播,导致那一瞬间卡顿得吓人。这就是《RimWorld》在爆炸袭击时会有几秒‘深思熟虑’停顿的原因。你得设置一个唤醒缓冲:区块要持续静止超过 3-10 tick + 有充足的事件队列空间,才把它睡下去;醒来则要么立即,要么延迟一个 tick 来分组唤醒,避免‘连环闹钟’效应。你以为你在节省计算,其实你在制造计算风暴。

第三层:并行管线——现代 CPU 的赠礼与诅咒

现代游戏开发早就不是单线程的‘老实人做工了’。你有一个 8 核 16 线程的 CPU,却让工厂逻辑跑在单线程上,就像开着航母去捕鱼——浪费草稿。但并行最大的问题不是让多个线程同时算,而是让它们算完之后,世界还能保持一致。如果你在不同线程算出结果后直接拼到一起,那世界的状态就乱套了:线程 A 把传送带上的物品往前推了一格,线程 B 同时发现那个位置有另一个物品,结果它们重叠了。

《异星工厂》的解法是‘区域并发 + 边界锁’。简单说,它把地图切成区块后,对每个区块分配一个独立的工作线程。每个线程只处理自己区块内部的实体,不跨区块读写数据。然后,区块与区块之间的交互(比如传送带跨边界)放到一个专门的‘边界处理器’里,这个处理器只处理边界上的少量实体,单线程就够。这样,区块内部的计算完全可以并行,而边界交互被串行化,把冲突面降到最低。

这就像一家大公司,各部门(区块)各自干活,相互之间不必同步,只有‘跨部门沟通’(边界交互)才需要开会。绝大部分工作都在各部门内部完成,所以开会频率其实很低。这套思路做出一个效果——当玩家在大型工厂里跑动时,整个世界在多个线程上同时‘呼吸’。你看着一条横跨地图的传送带,其实它的左半段在核心 3 上跑,右半段在核心 5 上跑,只有交界处的那一小段由主线程协调。

但这里的坑在于,并行不是免费的。每个区块的更新顺序必须是无副作用的——你不能让线程 A 先改完了、线程 B 才改,因为这会带来可见的不确定性。解决方案是让‘区块内部更新’本身不依赖外部状态,只计算本区块内的结果并写入‘临时缓冲’,等到所有线程跑完一轮(也就是一个全局 tick 的结束点),再统一用主线程把边界状态写回。这就是所谓的 double-buffering(双缓冲)思想的变体。你会多花一份内存,但换来了确定性和并行性的双赢。

更现实的问题是:线程切换与内存共享本身就是成本。当你只有 8 个区块在活跃时,开 16 个线程反而可能更慢。所以业界普遍的做法是动态线程池——活跃区块少时用 2-4 个线程,活跃区块多时扩展到 8-12 个。这需要你在区块唤醒/睡眠的时候动态调整线程数量。老实说,我在自己的小项目里翻过车:线程开多了,每 tick 结束后的同步开销比计算本身还贵,结果 60 UPS 硬生生掉到 40。后来我用‘每 4 tick 做一次全局同步,期间区块之间只走异步消息队列’才缓过来。

真正值得你抄的设计是《异星工厂》对并行和边界冲突的‘松绑’——对玩家来说,他们几乎从不关心‘两个传送带之间的物品到底是从左跑到右还是从右跑到左’,只要不出现明显的物体重叠和瞬移,一切物理合理就行。你可以在每个 tick 给边界交互一个‘两 tick 的排队窗口’,让物品在边界上有一个瞬间‘消失’再出现在另一侧的效果(配合恰当的渲染混滑),这既减少了同步频率,又天衣无缝地骗过了玩家的眼睛。

第四层:存档可复现——确定性 tick 的终极考验

这一层最容易被初学者忽视,但它是资深玩家社区信任你的基石。想象三种场景:你关掉游戏时工厂运行得好好的,第二天读档,发现所有机器都停转了——因为负载计算和流体模拟使用了随机值;你的多人联机队友说他那边的水管爆了,你这边却一切正常——因为你俩的 tick 顺序不同;或者你的游戏更新了一个小补丁,结果老存档里的生产线产量莫名其妙翻倍了——因为算法顺序动了。

可复现和确定性听起来是技术细节,但它直接决定了玩家对世界的信任:‘如果这个世界不恒定,我为什么还要投资几个小时去规划一条复杂产线?’《RimWorld》玩家有个很著名的梗,‘RimWorld 是个存档管理器,附带一个殖民模拟游戏’——这不完全是贬义词,因为它的存档(整个世界事件历史)就是玩家最珍贵的财产。只要你能保证确定性,你就能做存档回放、战役重播、甚至允许玩家对同一存档做实验分支。

要做到这一点,你必须把以下三个原则刻到骨子里:

原则解释出错的典型坑
固定 tick 时长模拟逻辑的更新始终以固定的游戏时间步长进行,不受帧率波动影响如果帧率偶尔 30 偶尔 60,前后两次测试结果会不一致
确定性调度顺序所有实体更新必须按固定顺序遍历(如按区块 ID 顺序),不能依赖哈希表随机遍历HashMap 的迭代顺序在不同运行时不一样,导致输出乱了
同帧内存撕裂防护更新完的中间状态绝不写入全局变量,要么双缓冲,要么立即处理掉一半实体用了新值,另一半用了旧值,世界状态被撕裂

其中‘确定性调度顺序’这事儿,说说容易,做起来暗坑无数。比如你要遍历一个‘活动区块列表’,你以为它是个数组,结果它在代码里是个哈希表,而哈希表的遍历顺序依赖于元素地址,程序一跑,顺序就随机了;今天跑出来的结果和明天不一样,存档就没法复现。你得把‘活动区块列表’固定成一个有序数组,每帧重新按区块 ID 排序,再按这个顺序去更新。这一点点处理,在《缺氧》的开发时就坑过他们一次——流体更新顺序随机化,导致两遍 run 结果不同,后来他们写了一个‘确定性的流体排序算法’固定用于更新。

还有一层的细节是‘浮点数不决定’——不同 CPU 架构、不同编译优化级别下浮点运算结果可能差 0.00001,而微小差异在复杂的连锁反应下会变成天壤之别。所以对需要绝对确定性同步的工厂游戏,最好用整数运算或定点数来表示位置、数量、百分比,或者至少把决策阈值设定得足够大,让微小误差不至于翻转结果。我自己在开发一个小石油化工厂玩法时吃过这个亏:一个储油罐液位是 0.03 和 0.0300001 的差别,导致两条管线不同时打开,表现在玩家眼里就是‘随机故障’。你这辈子都不想复现这个调试噩梦。

做到确定性后,你还能获得一个隐藏奖励——存档体积可以大幅压缩。既然世界状态在确定性的 tick 下是可复现的,你只要在存档里记录‘玩家手上的动作’和历史事件,不需要记录所有实体的位置快照。读档时从‘初始种子状态’开始重放若干 tick 就能恢复到完美状态。不过这招只对中小型场景(比如地图不大、前期不复杂)实用,大规模工厂还是老老实实存完整快照吧。

参数配置与建议区间:一张能让你少翻十年的表

把上面这些机制落到参数上,你可以直接照这个区间起步(然后根据目标平台微调)。

参数建议区间适用场景注意
基础 tick 频率(UPS)30-60 tick/sPC 大作(异星工厂 60 UP S);移动端 30 UPS 就够了低于 30 玩家会明显感到生涩,高于 60 则是浪费 CPU
流体/慢速系统 tick每 2-5 tick 一次管道网络、电网分配、经济系统太慢会让流体看起来‘卡顿’,2 tick 是下限
区块大小16×16 到 64×64 格玩家视觉焦点附近区块小些,远处区块大些区块太小→边界处理开销大;太大→并行粒度粗
区块休眠延迟3-10 tick动态实体暂停更新的缓和期延迟太短导致唤醒风暴,太长则休眠无效
并行线程数上限CPU_核心数 × 0.75 ~ 核心数取决于活跃区块数量线程过多→同步开销吞掉收益;建议动态
存档格式间隔每 10-30 秒自动存一次对确定性较高场景可延长频繁写档会卡顿,在后台线程做写操作

你现在就可以把表抄走,但有个更微妙的参数建议:把‘区块休眠延迟’和‘玩家感知’绑起来。如果玩家正在盯着一条传送带看,那这个区块别急着把它睡掉——玩家在看着的时候,哪怕画面静止也要保持模拟在跑,否则他们会在视觉上发现‘东西瞬移了’。这里可以做一个简单的启发式:把玩家视野覆盖的区块标记为‘永不休眠(除非暂停)’。

双路径:快path 与深path

初级用户快path(你只是想在几天内跑起一个可玩原型):别管并行、别管区块动态休眠,直接设一个 30 tick/s 的固定节拍器(固定时长的 timer,不是依赖帧的 update)。把所有实体的更新都塞进同一个函数里按顺序跑。你会发现地图小的时候,这种写法足够撑起几万个实体。第一版能玩比什么都重要。

进阶用户深path(你已经验证了玩法,现在要抗住十万人级规模):老老实实做区块静态划分 + 动态睡眠 + 双缓冲边界处理。然后做可复现性测试:每次构建后跑一遍相同的种子流程(初始化世界、执行 1000 tick),比对外部记录的状态哈希,一旦不一致立刻报警。这一步你可以在发行前一个月反复抓 root cause,省得发售后被玩家社区拿来当梗。

这两条路就像你在同一个工厂里,先用手工做出来几个样品,再建一条正经流水线——完全正常。别一上来就搞并行,会疯的。

FAQ:五个真问题

1. 什么叫‘确定性 tick 下的存档可复现’?存档只存事件不存状态,具体怎么操作?这适用于中小型场景。你把世界初始化种子(地形seed、配方列表版本、随机数种子)和玩家每次的交互操作(放置、拆除、调整参数)都记录成一个‘操作日志’。读档时,从初始状态开始按日志重放,就能得到一模一样的世界。工厂游戏适合吗?大工厂不适合——你用鼠标拖拽出来的蓝图操作序列能塞满整个硬盘。所以实际做法是折中:定期存全量快照,快照之间的操作以日志形式增量记录。

2. 帧率很低但 tick 很高,玩家会感受到什么?反而更卡吗?帧率低(比如 20 fps)但 tick 保持 60 UPS 时,画面会显得‘跳帧’但逻辑不会乱,机械臂的抓取不会丢。这是可接受的。反过来,帧率正常但 tick 掉到 20 UPS,玩家会看到传送带上物品‘一卡一卡’地移动,非常伤体验。所以优化的优先级永远是:‘保 tick 稳定’优先于‘保渲染帧率’,但你也需要保住最低 30 fps 来维持视觉连续性。

3. 区块休眠唤醒,会不会让边缘区域的怪物看不到玩家?会的。如果边缘区块睡眠后,没有规则主动唤醒它,AI 就感知不到玩家。解决方案是‘玩家视野周边的区块永不睡眠’ + ‘事件轮询唤醒’:每 N tick 检查一次玩家附近的区块是否有活跃需求,如果有就强制提前唤醒。代价是这部分永远占一些 CPU,但可控。

4. 如果我用多线程并行更新,怎么保证存档可复现?并行更新的每个区块内部依然用确定性顺序(区块 ID 排序);跨线程的边界交互统一在全局同步点顺序处理(比如按区块 ID 排列后逐个处理)。这样无论线程调度如何,最终整合结果是一致的。只要你在每 tick 末尾做一个全局屏障,并保证所有线程只写各自的缓冲,结果就是确定的。

5. 玩家会用测试工具来‘测 UPS’,是不是发烧友的爱好?是,但这也是你最好的 QA 团队。他们会发现你某个版本在超大型工厂下 UPS 暴跌——这信息比什么反馈都宝贵。所以你有责任做一件事:提供调试显示的 UPS/FPS 面板。它能帮玩家定位问题,也能帮你收集数据。别藏着掖着实测数字,社区会感激你。

文章标签
自动化工厂策划Factorio-like流水线设计传送带吞吐物流网络寻路科研树节奏蓝图系统海量实体优化比例规划数学产能瓶颈削减理论优化即乐趣
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:自动化工厂流水线(Automation / Factory)游戏策划专题