游戏架构实践
因果层在游戏中的职责
在游戏中,因果层需要构建一个最小因果裁决机制。它关心的不是渲染、动画或物理表现,而是权威事实的形成。
例如一次攻击成立后,因果层真正关心的是:
attack_requested -> buff_applied -> damage_applied -> hp_depleted -> entity_died
这些是离散的、可解释的、可记录的世界事实。
它的核心工程要求是:
- 裁决:所有会影响未来世界事实的变化,最终都必须被排序并判定合法性
- 确定性:同样的初始状态与同样的因果输入,必须得到同样的结果
感知层在游戏中的职责
游戏中的感知层是一个响应式运行时。它订阅因果层发布的事实,派生局部状态,驱动渲染、动画、物理反馈和交互,并在边界处将外部输入整理成语义事件上报。
它负责:
- 订阅世界事实
- 派生动画、镜头、UI 等局部状态
- 组织连续表现
- 检测玩家输入与局部碰撞
- 在边界时向因果层上报语义事件
它可以高度自治,但没有世界事实的裁决权。
轻量级演算与高保真本地模拟
很容易被误解的一点是为保证同步而将全部状态纳入因果层,会导致因果层膨胀为新的状态中心,丧失分层意义。
轻量级演算
轻量级演算只负责形成世界事实所需的最小权威计算。它属于因果层,数据驱动,目标是裁决结果,不在于复现全部连续过程。
例如:
- 这发子弹是否真正命中
- 这次攻击最终是命中、闪避还是格挡
- 空中角色落地时的状态切换
高保真本地模拟
高保真本地模拟负责连续表现、局部物理、插值、动画、空间反馈等丰富过程。它属于感知层,目标是形成体验,也不直接定义世界事实。
例如:
- 子弹拖尾与轨迹视觉
- 布料摆动、碎片飞散、粒子扩散
- 相机跟随、血条补间、受击震屏
- 局部碰撞反馈与空间插值
Position 是游戏中一个很容易混淆的例子。角色每一帧都在移动,但因果层不可能也不应该同步每一帧的坐标。
这时候需要换一个思路:世界真正关心的是 Position 本身,还是 Position 所表达的关系?
很多情况下答案是后者。就像一个国家不会记录你每时每刻的坐标,它只会在你出境、入境时记录出入境记录。
同样的道理:
角色进入 BOSS 房间 → 触发战斗
角色越过终点线 → 完成比赛
子弹命中目标 → 造成伤害
角色落地 → 可以再次跳跃
这些场景中,Position 从来不是事实。 真正的事实是“进入了战斗状态”、“跨越了边界”、“发生了命中”、“空中状态切换为地面状态”。
Position 本身不必天然进入因果层;只有当位置变化开始参与事件成立、冲突裁决和未来事实形成时,影响这个变化的事件才需要被提升为世界事实的一部分。
一旦将某个状态交给因果层维护,就会将受这个状态影响的其他状态也放到因果层,最后滚雪球般越滚越大,相当于又回到了原有的面向对象。
这也是关系型建模和状态型建模的区别,因果层关注的永远都是状态之间的变化关系,它维护的是导致状态变化的关系,而不是状态本身。
两种同步方式
因果层和感知层彼此隔离,不直接共享裁决权,但必须通过协议同步世界。游戏里的同步方式有两种。
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 - 订阅:感知节点只订阅自己关心的状态片段
- 派生:感知层基于订阅状态生成动画目标值、镜头目标、血条显示值等局部状态
- 执行:动画、物理反馈、渲染系统围绕这些派生状态持续运行
这也是为什么感知层更像现代前端:它是权威状态的响应式投影运行时。
同步与丢弃
感知层的连续过程也有边界。两层之间必须在关键时刻对齐:
- 对齐:因果层发生离散事件时,感知层必须承认最新事实
- 过渡:两次对齐之间,感知层可以自由插值、预测和编排动画
- 丢弃:如果新事实已经到达,旧的连续过程即使还没播完,就必须打断,快速过渡到最新事实
例如目标已经死亡,但死亡动画还没播完;复活事件已经下发,感知层就必须中止旧过程,迅速过渡到新事实。
对齐时刻必须承认最新事实,过渡过程中可以自由发挥。
感知层的工程实现
由于因果层充当了权威事实来源,感知层更适合用响应式的方式来组织代码。通过借鉴现代前端「UI=F(state)」的思路,但不是把前端运行时搬进游戏:权威事实是数据源,感知节点订阅事实并派生出要表现的状态,真正的动画、物理、渲染仍由游戏引擎驱动。
这里说的「响应式」是一种代码编写,组织方式,并非是一种新的运行时调度机制。游戏感知层的声明式空间组件最终还是会映射成对游戏引擎的命令式调用。响应式的价值在于让开发者以「订阅事实、派生状态」的方式组织代码,不用手动管理一堆对象回调和生命周期。
命令式 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)的思路:事实变化驱动细粒度订阅更新。
引擎协议
感知层不直接调用引擎的具体 API,而是通过项目内部定义的接口来操作引擎。这就像前端业务代码不直接调用浏览器底层绘制函数,而是通过 DOM API 来组织代码一样。
前端工程经过几十年的演化,形成了一套清晰的分层:
业务组件 → 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 把浏览器能力暴露给前端,这样前端不需要掌握每个浏览器不同的接口实现,同样的道理,引擎协议把游戏引擎能力暴露给感知层。
它把游戏引擎能力(创建相机、播放动画、查询物理等)暴露给业务代码,让业务代码不直接依赖 Unity 的 GameObject 或 Unreal 的 AActor,而是让感知层的业务代码只依赖引擎能力,不应当依赖具体引擎:
ICamera — 创建相机、设置视角、跟随目标、震屏
ILighting — 创建光源、设置颜色强度
IRenderable — 创建模型、设置变换、播放动画
IPhysicsWorld — 射线检测、区域查询
IParticleSystem — 触发粒子特效
ISpatialUI — 世界空间 UI,比如血条、伤害数字
IAudioWorld — 空间音效、背景音乐
空间 DOM 描述的是世界结构,既包括实体层级(角色、敌人、道具),也包括全局配置组件(物理、渲染、音频、光照)。实体组件依附于具体实体并随其生灭;配置组件作为根级组件,描述全局环境与系统能力。它们都通过订阅事实来响应世界变化,最终由引擎协议映射到具体引擎实现。
引擎逻辑,游戏逻辑,游戏表现三者实现解耦,最终达到一种“一切皆组件,一切皆订阅“的理想开发环境。
这样做的好处
第一,业务代码声明式。
只需要描述"世界里有什么",不需要去创建 GameObject。让业务代码围绕"空间组件"和"事实"来组织,而不是围绕引擎对象的生命周期和回调来组织。
第二,组件化
就像前端可以用 <Card><Header/><Body/></Card> 组合 UI 一样,游戏里也可以组合空间组件:
<Enemy entity={e}>
<Model asset="goblin" />
<Animator />
<HealthBar />
<HitEffect />
</Enemy>
所有的空间组件都可以像现代前端那样可组合、可拆分、可嵌套、可独立测试,轻量化开发。
第三,引擎解耦。
日常开发中,底层引擎细节不会污染业务代码。所有人可以通用一套引擎协议,降低心智负担,不需要深入掌握底层引擎知识,将业务逻辑与引擎逻辑进行解耦。
自治边界
感知层可以拥有很多局部状态,但不能私自修改世界事实。
下面是一张实用的边界表:
| 范围 | 感知层 | 因果层 |
|---|---|---|
| 视觉表现 | 动画、粒子、镜头、UI 过渡、拖尾、雨雪特效 | — |
| 局部反馈 | Hover、按下反馈、拖拽预览、提示圈预热 | 确认、释放、真正生效时 |
| 连续物理 | 局部碰撞反馈、轨迹插值、布料、碎片、刚体震荡 | 命中、落地、进入区域、位移成立 |
| 空间感知 | 检测候选碰撞、检测候选目标、检测边界跨越 | 命中确认、目标切换确认、状态切换确认 |
| 行为决策 | — | 攻击、技能、受击、死亡、切阶段、切状态 |
核心判断标准:
如果这个结果会影响未来世界事实,它最终就不能只停留在感知层,因果层应当只维护轻量级最小权威逻辑运算。