跳到主要内容

游戏架构实践

因果层:世界事实的权威来源

因果层是游戏世界的“真相”。它负责维护权威状态、解释事件、裁决结果。

它的核心特征有两个:

  • 裁决:所有会影响未来世界事实的变化,最终都必须被排序并判定合法性
  • 确定性:同样的初始状态与同样的因果输入,必须得到同样的结果

在因果层中你需要构建一个最小游戏因果裁决机制。

例如一次攻击成立后,因果层真正关心的是:

attack_requested -> Buff -> damage_applied -> hp_changed -> entity_died

这些是离散的、可解释的、可记录的世界事实。

感知层:世界事实的响应式投影

感知层如果只被理解成“表现层”,其实是不够的。更贴切地说,它是一个原生实现帧处理的响应式运行时。它像现代前端一样,持续订阅权威状态,派生局部状态,驱动动画、物理反馈、渲染和交互。

感知层负责的是:

  • 订阅世界事实
  • 派生局部状态
  • 组织连续表现
  • 检测外部输入
  • 在边界时向因果层上报语义事件

它可以高度自治,但没有世界事实的裁决权。


轻量级演算与完整自治模拟

双世界在游戏工程里最容易被误解的一点,是把“进入因果层”理解成“所有连续过程都要在逻辑层完整重跑”。

轻量级演算

轻量级演算只负责形成世界事实所需的最小权威计算。它属于因果层,目标是裁决结果,不在于复现全部连续过程。

例如:

  • 这发子弹是否真正命中
  • 这次攻击最终是命中、闪避还是格挡
  • 角色是否真正落地
  • 冲刺是否因为撞墙而终止

完整自治模拟

完整自治模拟负责连续表现、局部物理、插值、动画、空间反馈等丰富过程。它属于感知层,目标是形成体验,也不直接定义世界事实。

例如:

  • 子弹拖尾与轨迹视觉
  • 布料摆动、碎片飞散、粒子扩散
  • 相机跟随、血条补间、受击震屏
  • 局部碰撞反馈与空间插值

值得注意的是,Position 这个东西,游戏中存在大量改变了空间位置就导致世界状态发生变化的事件,所以必须将其放在因果层?但是如果要在因果层中完全模拟空间位置,工程上又完全不现实

这时候需要换一个思路,世界真正关心的是 Position 本身,还是 Position 所表达的关系?

很多情况下,答案是后者,就像一个国家他不会完全观察你每时每刻的坐标位置,他只会在你出境入境时,上报你的出入境记录。

同样的道理,Position 往往只是计算手段,不是最终事实

角色进入 BOSS 房间 → 触发战斗

角色越过终点线 → 完成比赛

子弹命中目标 → 造成伤害

角色落地 → 可以再次跳跃

千万不能将因果层的关系事件与感知层的计算模拟混为一谈。

因果层负责事实所需的最小权威演算;感知层负责连续过程的丰富自治模拟。


两种同步方式

因果层和感知层彼此隔离,不直接共享裁决权,但必须通过协议同步世界。游戏里的同步方式有两种。

1. 确定性事件同步

当因果层已经完成裁决,结果就可以作为确定性事件下发给感知层。

例如:

  • damage_applied
  • entity_died
  • phase_changed

这类事件的特点是:

  • 因果层已经给出唯一结果
  • 感知层只负责消费和呈现
  • 感知层可以自由选择动画、镜头和特效语言
  • 但不能改写结果本身

比如“一刀斩杀 1000 个敌人”,真正的因果工作已经结束;感知层接下来要解决的是怎么分帧播放动画、怎么批量演出、怎么做镜头与粒子,结果本身已经确定。

2. 不确定性窗口同步

有些交互在进入系统时还没有最终结果,需要等待时间、输入或外部事件收敛,这就需要不确定性窗口。

例如:

  • 攻击可能命中、闪避、格挡、受击、被打断
  • 蓄力可能完成,也可能中途取消
  • QTE 可能成功,也可能失败

这里的“不确定”,指的是这次执行最终落到哪条已定义路径上还没有确定

也就是说,路径集合本身是既定的,尚未确定的只是这次会落到哪一条。

这类结果通常是有限分支下的确定性裁决,因此非常适合做回放、回滚和优化。类似 GGPO 的思路本质上也证明了:只要核心裁决层足够轻、足够确定,复杂实时交互并不会天然拖垮性能。

这种不确定性窗口可以抽象成临时状态窗口。它是因果层内部用于处理不确定性交互的受控生命周期单元,在有限时间内监听事件和时间流逝,在结束时提交一个确定结论,在被抢占时安全取消。

临时状态窗口有四个关键属性:

  • 有时效:窗口关闭后,内部过程状态自然消亡
  • 可抢占:更高优先级的事件到达时,旧窗口可以被取消
  • 沙盒化:窗口只产出结论,不直接持有长期世界事实,满足最终因果一致性

例如一次格挡判定窗口:

  1. 因果层创建一个持续 0.3s 的格挡窗口
  2. 窗口监听时间流逝与受击事件
  3. 若在窗口内命中,则提交 guard_success
  4. 若窗口结束仍未命中,则提交 guard_timeout
  5. 若角色被眩晕或状态切换,则窗口被抢占并取消

在代码工程上,这类窗口非常适合通过结构化并发实现。


输入、状态与对齐

输入信号

玩家输入、摇杆、拖拽、瞄准、局部碰撞,很多都是连续信号。但因果层只消费离散语义事件。

例如:

  • 按键按下 -> attack_requested
  • 松开蓄力键 -> charge_released
  • 局部碰撞触发命中边界 -> hit_candidate_detected

感知层不能直接改世界状态,它只能上报封装良好的语义事件或候选事实。

状态订阅

感知层不应直接读写因果层的原始状态树,而应通过协议消费权威变化。

最典型的形式是:

  1. 发布:因果层发布 snapshot / patches / timeline
  2. 订阅:感知节点只订阅自己关心的状态片段
  3. 派生:感知层基于订阅状态生成动画目标值、镜头目标、血条显示值等局部状态
  4. 执行:动画、物理反馈、渲染系统围绕这些派生状态持续运行

这也是为什么感知层更像现代前端:它是权威状态的响应式投影运行时。

同步与丢弃

感知层的连续过程也有边界。两层之间必须在关键时刻对齐:

  • 对齐:因果层发生离散事件时,感知层必须承认最新事实
  • 过渡:两次对齐之间,感知层可以自由插值、预测和编排动画
  • 丢弃:如果新事实已经到达,旧的连续过程即使还没播完,就必须打断,快速过渡到最新事实

例如目标已经死亡,但死亡动画还没播完;复活事件已经下发,感知层就必须中止旧过程,迅速过渡到新事实。

对齐时刻必须承认最新事实,过渡过程中可以自由发挥。


感知层的工程实现

由于因果层充当了权威事实来源,感知层更适合用响应式。它更像是一种能够原生处理帧驱动的理想化现代前端:权威事实是 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、按下反馈、拖拽预览、提示圈预热确认、释放、真正生效时
连续物理局部碰撞反馈、轨迹插值、布料、碎片、刚体震荡命中、落地、进入区域、位移成立
空间感知检测候选碰撞、检测候选目标、检测边界跨越命中确认、目标切换确认、状态切换确认
行为决策攻击、技能、受击、死亡、切阶段、切状态

核心判断标准:

如果这个结果会影响未来世界事实,它最终就不能只停留在感知层,因果层永远只维护轻量级最小权威逻辑运算。


相关阅读