游戏架构实践
因果层:世界事实的权威来源
因果层是游戏世界的“真相”。它负责维护权威状态、解释事件、裁决结果。
它的核心特征有两个:
- 裁决:所有会影响未来世界事实的变化,最终都必须被排序并判定合法性
- 确定性:同样的初始状态与同样的因果输入,必须得到同样的结果
在因果层中你需要构建一个最小游戏因果裁决机制。
例如一次攻击成立后,因果层真正关心的是:
attack_requested -> Buff -> damage_applied -> hp_changed -> entity_died
这些是离散的、可解释的、可记录的世界事实。
感知层:世界事实的响应式投影
感知层如果只被理解成“表现层”,其实是不够的。更贴切地说,它是一个原生实现帧处理的响应式运行时。它像现代前端一样,持续订阅权威状态,派生局部状态,驱动动画、物理反馈、渲染和交互。
感知层负责的是:
- 订阅世界事实
- 派生局部状态
- 组织连续表现
- 检测外部输入
- 在边界时向因果层上报语义事件
它可以高度自治,但没有世界事实的裁决权。
轻量级演算与完整自治模拟
双世界在游戏工程里最容易被误解的一点,是把“进入因果层”理解成“所有连续过程都要在逻辑层完整重跑”。
轻量级演算
轻量级演算只负责形成世界事实所需的最小权威计算。它属于因果层,目标是裁决结果,不在于复现全部连续过程。
例如:
- 这发子弹是否真正命中
- 这次攻击最终是命中、闪避还是格挡
- 角色是否真正落地
- 冲刺是否因为撞墙而终止
完整自治模拟
完整自治模拟负责连续表现、局部物理、插值、动画、空间反馈等丰富过程。它属于感知层,目标是形成体验,也不直接定义世界事实。
例如:
- 子弹拖尾与轨迹视觉
- 布料摆动、碎片飞散、粒子扩散
- 相机跟随、血条补间、受击震屏
- 局部碰撞反馈与空间插值
值得注意的是,Position 这个东西,游戏中存在大量改变了空间位置就导致世界状态发生变化的事件,所以必须将其放在因果层?但是如果要在因果层中完全模拟空间位置,工程上又完全不现实
这时候需要换一个思路,世界真正关心的是 Position 本身,还是 Position 所表达的关系?
很多情况下,答案是后者,就像一个国家他不会完全观察你每时每刻的坐标位置,他只会在你出境入境时,上报你的出入境记录。
同样的道理,Position 往往只是计算手段,不是最终事实
角色进入 BOSS 房间 → 触发战斗
角色越过终点线 → 完成比赛
子弹命中目标 → 造成伤害
角色落地 → 可以再次跳跃
千万不能将因果层的关系事件与感知层的计算模拟混为一谈。
因果层负责事实所需的最小权威演算;感知层负责连续过程的丰富自治模拟。
两种同步方式
因果层和感知层彼此隔离,不直接共享裁决权,但必须通过协议同步世界。游戏里的同步方式有两种。
1. 确定性事件同步
当因果层已经完成裁决,结果就可以作为确定性事件下发给感知层。
例如:
damage_appliedentity_diedphase_changed
这类事件的特点是:
- 因果层已经给出唯一结果
- 感知层只负责消费和呈现
- 感知层可以自由选择动画、镜头和特效语言
- 但不能改写结果本身
比如“一刀斩杀 1000 个敌人”,真正的因果工作已经结束;感知层接下来要解决的是怎么分帧播放动画、怎么批量演出、怎么做镜头与粒子,结果本身已经确定。
2. 不确定性窗口同步
有些交互在进入系统时还没有最终结果,需要等待时间、输入或外部事件收敛,这就需要不确定性窗口。
例如:
- 攻击可能命中、闪避、格挡、受击、被打断
- 蓄力可能完成,也可能中途取消
- QTE 可能成功,也可能失败
这里的“不确定”,指的是这次执行最终落到哪条已定义路径上还没有确定。
也就是说,路径集合本身是既定的,尚未确定的只是这次会落到哪一条。
这类结果通常是有限分支下的确定性裁决,因此非常适合做回放、回滚和优化。类似 GGPO 的思路本质上也证明了:只要核心裁决层足够轻、足够确定,复杂实时交互并不会天然拖垮性能。
这种不确定性窗口可以抽象成临时状态窗口。它是因果层内部用于处理不确定性交互的受控生命周期单元,在有限时间内监听事件和时间流逝,在结束时提交一个确定结论,在被抢占时安全取消。
临时状态窗口有四个关键属性:
- 有时效:窗口关闭后,内部过程状态自然消亡
- 可抢占:更高优先级的事件到达时,旧窗口可以被取消
- 沙盒化:窗口只产出结论,不直接持有长期世界事实,满足最终因果一致性
例如一次格挡判定窗口:
- 因果层创建一个持续
0.3s的格挡窗口 - 窗口监听时间流逝与受击事件
- 若在窗口内命中,则提交
guard_success - 若窗口结束仍未命中,则提交
guard_timeout - 若角色被眩晕或状态切换,则窗口被抢占并取消
在代码工程上,这类窗口非常适合通过结构化并发实现。
输入、状态与对齐
输入信号
玩家输入、摇杆、拖拽、瞄准、局部碰撞,很多都是连续信号。但因果层只消费离散语义事件。
例如:
- 按键按下 ->
attack_requested - 松开蓄力键 ->
charge_released - 局部碰撞触发命中边界 ->
hit_candidate_detected
感知层不能直接改世界状态,它只能上报封装良好的语义事件或候选事实。
状态订阅
感知层不应直接读写因果层的原始状态树,而应通过协议消费权威变化。
最典型的形式是:
- 发布:因果层发布
snapshot / patches / timeline - 订阅:感知节点只订阅自己关心的状态片段
- 派生:感知层基于订阅状态生成动画目标值、镜头目标、血条显示值等局部状态
- 执行:动画、物理反馈、渲染系统围绕这些派生状态持续运行
这也是为什么感知层更像现代前端:它是权威状态的响应式投影运行时。
同步与丢弃
感知层的连续过程也有边界。两层之间必须在关键时刻对齐:
- 对齐:因果层发生离散事件时,感知层必须承认最新事实
- 过渡:两次对齐之间,感知层可以自由插值、预测和编排动画
- 丢弃:如果新事实已经到达,旧的连续过程即使还没播完,就必须打断,快速过渡到最新事实
例如目标已经死亡,但死亡动画还没播完;复活事件已经下发,感知层就必须中止旧过程,迅速过渡到新事实。
对齐时刻必须承认最新事实,过渡过程中可以自由发挥。
感知层的工程实现
由于因果层充当了权威事实来源,感知层更适合用响应式。它更像是一种能够原生处理帧驱动的理想化现代前端:权威事实是 store,感知节点是组件,动画和物理是响应式派生,帧循环是它的渲染调度器。
命令式 vs 响应式
传统游戏开发里,感知层往往是命令式的:
enemy.TakeDamage(10);
enemy.PlayHitAnimation();
ui.ShowDamageNumber(enemy, 10);
你主动告诉每个对象该做什么。对象之间直接调用,状态分散在各自的回调里,因果链藏在调用关系后面。
onFact('damage_applied', (fact) => {
animation.play('hit', fact.targetId);
ui.showDamageNumber(fact.targetId, fact.amount);
});
你声明"当某个事实发生时,我要做什么"。感知节点只订阅事实,不直接调用其他对象。
为什么感知层要响应式
这个问题很多年以前前端已经问过了一次:为什么有了 jQuery,还需要 React?
感知层的职责是"呈现事实"。同一个事实可能触发多种呈现:
- 死亡事实 → 播动画、更新 UI、触发任务、记录统计
- 伤害事实 → 飘字、震屏、血条变化
如果用命令式,每增加一种呈现,就要去改"产生这个事实"的代码,在无数的对象回调中修改。用响应式,新增一个订阅者即可。
这其实就是传统前端与现代前端的区别。现代前端最终取代传统前端,原因之一就是它能有效应对软件工程中最大的敌人——「熵增」。
响应式也让"对齐"和"丢弃"变得自然。新事实到达时,旧的动画订阅可以被取消或覆盖,感知层迅速过渡到新的权威状态,而不需要手动清理一堆对象回调。
代码实现可以参考的SolidJS的源码,每次都是细粒度的更新,而且在编译期已经解决了闭包优化问题。
引擎协议
感知层不直接操作引擎,就像前端不直接操作浏览器。
前端工程经过几十年的演化,形成了一套清晰的分层:
业务组件 → UI 组件库 → React/Vue → DOM → Web API → 浏览器引擎
游戏感知层也可以建立同样的分层:
业务空间组件 → 空间组件库 → 空间组件运行时 → 空间 DOM → 引擎协议 → 游戏引擎
前端怎么工作
前端写页面,不会直接调用浏览器的绘制函数,而是写:
<div class="card">
<h1>标题</h1>
<button>点击</button>
</div>
浏览器会帮你做三件事:
- DOM 描述结构:把 HTML 解析成一棵树
- Web API 提供能力:事件监听、绘制、音频、网络
- 渲染引擎执行:Blink、Gecko、WebKit 把 DOM 画出来
同一套 HTML/CSS/JavaScript,可以跑在不同浏览器上,因为大家遵守的是同一套协议。
游戏感知层也可以这样工作
游戏感知层写场景,不应该直接调用 Instantiate(GameObject) ,直接写:
<Scene>
<Camera target={player} fov={60} />
<DirectionalLight direction={sunDir} />
<Character entity={player}>
<Weapon slot="main_hand" />
<HealthBar />
</Character>
{enemies.map(e => <Enemy key={e.id} entity={e} />)}
</Scene>
然后由运行时自动装箱:
- 空间 DOM 描述结构:把空间组件解析成场景图
- 引擎协议提供能力:创建模型、控制相机、播放动画、查询物理
- 游戏引擎执行:调用引擎把场景画出来
引擎协议就是空间版的 Web API
Web API 把浏览器能力暴露给前端,引擎协议把游戏引擎能力暴露给感知层。
引擎协议不应该直接封装 Unity 的 GameObject 或 Unreal 的 AActor,而是让感知层的业务代码只依赖引擎的通用能力,不应当依赖具体引擎:
ICamera — 创建相机、设置视角、跟随目标、震屏
ILighting — 创建光源、设置颜色强度
IRenderable — 创建模型、设置变换、播放动画
IPhysicsWorld — 射线检测、区域查询
IParticleSystem — 触发粒子特效
ISpatialUI — 世界空间 UI,比如血条、伤害数字
IAudioWorld — 空间音效、背景音乐
这样做的好处
第一,业务代码声明式。
只需要描述"世界里有什么",不需要去创建 GameObject。让业务代码围绕"空间组件"和"事实"来组织,而不是围绕引擎对象的生命周期和回调来组织。
第二,组件可组合。
就像前端可以用 <Card><Header/><Body/></Card> 组合 UI 一样,游戏里也可以组合空间组件:
<Enemy entity={e}>
<Model asset="goblin" />
<Animator />
<HealthBar />
<HitEffect />
</Enemy>
所有的空间组件都可以像现代前端那样可组合、可拆分、可嵌套、可独立测试,轻量化开发。
第三,引擎解耦。
日常开发中,底层引擎细节不会污染业务代码。所有人可以通用一套引擎协议,不需要深入掌握底层引擎知识,大大提高开发效率。
自治边界
感知层可以拥有很多局部状态,但不能拥有世界事实主权。
下面是一张实用的边界表:
| 范围 | 感知层可自治 | 必须进入因果层 |
|---|---|---|
| 视觉表现 | 动画、粒子、镜头、UI 过渡、拖尾、雨雪特效 | — |
| 局部反馈 | Hover、按下反馈、拖拽预览、提示圈预热 | 确认、释放、真正生效时 |
| 连续物理 | 局部碰撞反馈、轨迹插值、布料、碎片、刚体震荡 | 命中、落地、进入区域、位移成立 |
| 空间感知 | 检测候选碰撞、检测候选目标、检测边界跨越 | 命中确认、目标切换确认、状态切换确认 |
| 行为决策 | — | 攻击、技能、受击、死亡、切阶段、切状态 |
核心判断标准:
如果这个结果会影响未来世界事实,它最终就不能只停留在感知层,因果层永远只维护轻量级最小权威逻辑运算。