确定性模拟经常被讲得很神秘,好像只要项目用了帧同步、固定随机种子和整数运算,就能得到一个优雅的实时战斗系统。真实项目里没这么简单。确定性模拟确实强大,但它也有成本:工程约束更严、调试更难、跨平台差异更敏感、表现层要隔离,团队所有人都必须理解哪些代码会影响模拟结果。
更重要的是,不是所有游戏都需要严格确定性。回合制、棋盘、卡牌、部分 Roguelike 战斗和轻量策略模拟,确定性可以带来回放、验证和低带宽同步收益。强表现动作、复杂物理、开放世界和大量非规则交互的项目,如果为了确定性把开发成本拉得过高,未必划算。
所以讨论确定性模拟时,第一件事不是“怎么实现”,而是“哪些结果必须确定,哪些表现可以不确定”。
一个典型误区:把表现也拉进确定性
有个项目早期想做完整确定性战斗,结果把动画事件、粒子出生、音效随机、镜头震动甚至 UI 飘字顺序都纳入了同一套随机序列。上线前调试回放时,某个特效多消耗了一次随机数,后面的暴击判定全部偏移。修完以后大家才意识到,表现随机不应该污染战斗随机。
确定性的关键是保护“规则结果”,而不是让屏幕上每一粒火星都一样。伤害、命中、移动、技能状态、Buff、结算需要可验证;粒子扰动、音频随机、镜头轻微抖动可以独立使用表现随机,甚至完全不参与回放验证。
明确模拟层和表现层
确定性项目最重要的边界,是模拟层和表现层分离。模拟层只处理规则:
- 输入。
- 时间步。
- 位置和速度。
- 碰撞或格子关系。
- 技能状态。
- Buff 和数值。
- 随机结果。
- 胜负和结算。
表现层处理玩家看到和听到的内容:
- 动画。
- 特效。
- 音效。
- 镜头。
- UI。
- 震动。
- 插值和平滑。
表现层可以根据模拟结果播放,但不能反向影响模拟。比如动画事件可以触发命中特效,但不应该决定命中帧;镜头震动可以根据伤害等级播放,但不应该改变角色位置;UI 按钮可以显示冷却,但冷却计算不应该依赖 UI 更新频率。
固定时间步不是可选项
确定性模拟通常需要固定时间步。不能因为某台机器帧率高就多算一点,也不能因为低端机卡顿就少算一点。渲染帧和模拟帧应该分开:模拟按固定 tick 推进,渲染在两个模拟状态之间插值。
如果模拟直接使用 deltaTime,浮点误差和帧率差异很容易让结果分叉。比如一个持续 3 秒的 Buff,在 60 FPS 和 57 FPS 下累计时间可能略有不同;一个移动单位的小数误差,在几百帧后可能导致碰撞边界不同。
固定时间步也会带来压力:低端机跟不上时怎么办?常见策略是限制单帧补模拟次数,超过后降级、慢放或断开同步,而不是无限补帧把客户端卡死。
随机数要有专用流
确定性里的随机数必须可控。不要随手调用系统随机,也不要让表现层和规则层共用同一个随机流。建议按用途拆分:
- 战斗规则随机。
- AI 决策随机。
- 掉落或结算随机。
- 表现随机。
其中规则随机必须记录种子和调用顺序,表现随机可以独立。调试时最好能输出随机调用日志:第几帧、哪个系统、请求了什么随机值。否则一旦回放分叉,很难知道是谁多调用了一次随机。
如果项目有服务端验证,关键随机最好由服务端种子或服务端结果控制。客户端可以本地模拟,但不能单独决定经济或竞技结果。
浮点误差要提前限制
跨平台浮点差异是真实存在的,尤其是不同 CPU、不同编译器、不同数学库。确定性要求很高的项目会使用定点数或整数网格,降低浮点误差。并不是所有项目都要做到这个程度,但必须知道风险。
如果仍然使用浮点,要尽量避免:
- 不同平台数学函数参与规则结果。
- 遍历无序集合导致计算顺序变化。
- 依赖物理引擎的非确定性碰撞结果。
- 用浮点累计长时间计时。
- 在规则层使用表现层插值结果。
很多项目的折中方案是:核心规则用整数或定点,表现层用浮点。这样规则稳定,画面仍然可以平滑。
回放验证是必要工具
确定性不是写完就能相信的。必须有回放验证:记录输入、种子、配置版本和关键状态哈希,然后重放,比较每隔若干帧的哈希是否一致。
哈希不要只在最后比较。最后才发现不同,基本不知道从哪里分叉。更好的方式是每 10 或 20 个 tick 记录一次关键状态哈希,包括单位位置、生命、技能状态、Buff、随机流位置。分叉后可以二分定位到具体帧。
测试用例也要覆盖真实场景:多单位、同时释放技能、召唤物、控制效果、死亡回收、断线恢复、倍速、暂停、后台切回。确定性问题往往出现在边界组合里。
与状态同步的取舍
确定性模拟和状态同步不是谁更高级的问题。确定性同步带宽低、回放强、规则一致,但工程约束严;状态同步实现直观、服务端权威强,但带宽和修正成本更高。很多项目会混合使用:核心战斗按输入和种子模拟,关键节点由服务端校验;远程表现用快照插值;结算由服务端确认。
选择时可以问几个问题:
- 游戏是否需要精确回放。
- 单局人数和单位数量是否很高。
- 规则是否能接受严格工程约束。
- 是否大量依赖非确定性物理。
- 团队是否有工具维护确定性。
- 作弊风险是否需要服务端强校验。
如果团队没有回放验证、状态哈希和分叉定位工具,贸然使用严格确定性会很痛苦。
上线前检查清单
- 规则层和表现层是否完全分开。
- 模拟是否使用固定 tick。
- 规则随机和表现随机是否隔离。
- 关键状态是否能生成哈希。
- 回放分叉是否能定位到具体帧。
- 是否避免无序集合遍历影响结果。
- 关键经济和竞技结果是否有服务端校验。
- 不同平台是否跑过同一输入的确定性测试。
结语
确定性模拟不是银弹,它是一套严格的工程契约。用得好,可以带来低带宽同步、可靠回放和清晰验证;用得不好,会让每个随机数、每个浮点误差、每个动画事件都变成潜在分叉点。客户端团队真正要做的,是先决定哪些结果必须确定,再为这些结果建立边界、工具和测试,而不是盲目追求“所有东西完全一样”。
进一步工程化落地
确定性模拟最怕“感觉上应该一致”。工程上必须用工具证明一致。建议从最小战斗片段开始:两名角色、一个技能、一个 Buff、一组固定输入,跑 1000 次重放,比较状态哈希。通过以后再逐步加入召唤物、位移、控制、死亡、掉落、暂停和倍速。不要一开始就把完整战斗塞进确定性验证,否则分叉时很难定位。
第二个关键点是分叉诊断。每个 tick 的完整状态都打印出来不现实,但可以按固定间隔记录哈希,并在发现不同后自动二分。定位到具体帧后,再打开详细日志,输出参与计算的单位、随机流位置、输入包和关键配置。这样研发能在几分钟内知道是随机数多取了一次,还是某个平台的数学函数结果不同。
第三个关键点是代码评审规则。任何进入模拟层的代码,都要问它是否读取了系统时间、是否遍历无序集合、是否调用表现随机、是否依赖渲染帧、是否用了非确定性物理结果。确定性不是某个网络模块能单独保证的,它要求整个规则层保持纪律。
最后要明确降级策略。客户端短时间跟不上模拟时,是补帧、慢放、断开同步还是请求权威快照?这些策略要在设计阶段定好。确定性系统如果没有异常路径,真实设备上一旦遇到卡顿和后台切回,就会从“严格一致”变成“严格卡死”。
团队协作与验收方式
确定性模拟不是网络程序一个人的工作。战斗策划新增一个随机效果,AI 程序新增一次目标选择,表现程序在动画事件里多取一次随机数,都可能让回放分叉。因此团队需要一条规则:所有影响模拟结果的代码必须进入确定性测试,所有表现随机都要明确隔离。
验收时可以建立一组黄金回放。每个重要玩法版本都保存输入、配置、随机种子和期望哈希。后续任何代码改动都跑这批回放,如果哈希变化,必须说明原因。变化不一定都是错,比如策划调整技能数值会导致哈希变化,但变化必须可解释、可审查。
还要做跨平台验证。同一份输入在 macOS、Windows、Android、iOS 上跑出来是否一致,至少核心状态哈希要一致。不要等主机或移动端移植时才发现某个数学函数、浮点精度或容器遍历顺序不稳定。确定性越早纳入持续验证,后期越不容易被它反咬。
排查指标与复盘模板
这类系统上线后,建议保留一份简单复盘模板:问题发生的版本、命中的资源和配置、玩家操作路径、最近一次状态变化、是否有异常日志、是否可回放、最终根因属于规则、表现、资源、网络还是工具缺失。复盘不要只写“已修复”,还要写“下次如何提前发现”。如果是事件没解绑,就补事件订阅检查;如果是配置引用错误,就补构建校验;如果是低端机长测才出现,就补自动长测场景。
指标也要持续观察。实体数量、对象池峰值、未释放资源、事件订阅数、UI 绑定数、重连恢复耗时、异常降级次数,都可以成为开发包或灰度包里的诊断指标。它们不需要全部上报到正式环境,但团队要有办法在问题出现时快速查看。
真正有效的工程改进,往往不是修一次 Bug,而是把这次 Bug 变成一个检查点、一个自动测试、一个调试面板字段或一个构建期错误。这样文章里讲的经验才不会只停留在经验,而会变成项目的一部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。