3468 字
17 分钟
FPS僵尸,但是腿呢,可惜没有动画遮罩

我的第一个 UE5 FPS:从“枪能开火”到真正开始理解动画时序#

这个项目是我在学习 Unreal Engine 5 的过程中,通过逆向分析一个可运行的 FPS 实例项目完成的一次实践。

我没有拿着一份固定步骤逐项照做,而是先运行实例、观察表现,再从输入、角色、武器、动画和 UI 一层层追踪它的数据流。遇到看不懂的结构,就拆开验证;确认某段逻辑的职责后,再回到自己的工程里重新搭建。

如果只看最终效果,它其实并不复杂:角色可以移动、瞄准、开火、换弹,武器有后坐力、枪口火焰、弹壳、命中特效和基础 UI。

但真正让我记住这个项目的,并不是“终于做出了一把能开火的枪”,而是我第一次遇到了一个需要真正去理解 UE 动画更新机制才能解决的问题:

手臂 Animation Blueprint 和武器 Animation Blueprint 的计算时序不同步。

这也是我第一次明显感觉到,做游戏和“看懂一个实例表面上连了哪些节点”之间,其实差着很远。


从最基础的 FPS 功能开始#

项目一开始,我先把实例项目完整运行起来,记录玩家能够直接感受到的功能,然后再反向寻找每个效果的入口、状态来源与执行位置。

整个拆解过程大致是:

  • 确认项目使用的 GameMode 与角色类;
  • 从输入映射追踪移动、瞄准、开火和换弹;
  • 检查角色基础能力由谁持有;
  • 分析手臂与武器各自的动画系统;
  • 追踪 ADS 状态以及瞄准灵敏度的变化;
  • 拆解武器、弹药、表现层和 UI 之间的数据流;
  • 在自己的工程中重新组织并实现这些功能。

相比之前做的第三人称闯关项目,FPS 给我的第一个明显感受是:

第一人称视角会把很多细节问题直接放到玩家眼前。

第三人称里,角色离摄像机还有一定距离。

但在 FPS 里,手和枪几乎占据了屏幕中心区域。

所以只要动画慢一帧、姿势偏一点、枪械和手臂没有完全同步,玩家很容易立刻发现。

这也让我开始更重视动画、状态与表现之间的同步关系。


从“能开火”到“有射击感觉”#

完成角色基础能力之后,我开始沿着实例项目中的一次射击,逐段追踪它到底触发了哪些系统,并在自己的项目里重新实现。

我陆续拆解并完成了:

  • ADS 瞄准;
  • 开火动画;
  • 后坐力;
  • Niagara 枪口火焰;
  • MetaSound 开火音效;
  • 弹壳生成;
  • 弹孔;
  • 命中特效;
  • 命中音效;
  • 换弹系统;
  • 弹药管理;
  • 武器 UI。

这一阶段让我第一次比较直观地理解了所谓的 Game Feel。

从程序逻辑上来说,一次射击可能只是:

扣除一发子弹
↓
执行一次射线检测
↓
判断是否命中

这些逻辑已经足够构成“开枪”。

但玩家真正感受到的射击,其实还包括:

开火动画
枪口火焰
音效
后坐力
弹壳
命中特效
命中音效
UI 反馈

也就是说:

Gameplay Logic 决定“这枪有没有打出去”,表现层决定“这枪打出去是什么感觉”。

这是我第一次比较明确地区分“功能正确”和“体验完整”。


开始理解模块化,而不是把所有逻辑塞进角色蓝图#

在分析实例项目时,我也遇到了另一个很典型的问题:

如果所有逻辑都塞进 Character Blueprint,结构会迅速膨胀。

最开始可能只是:

Character
├── 移动
├── 跳跃
└── 开火

继续做下去以后就会变成:

Character
├── Input
├── Aim
├── Fire
├── Reload
├── Recoil
├── Ammo
├── UI
├── Animation
├── Audio
└── VFX

我没有只记录“某个节点连接到了哪里”,而是继续追踪每一段逻辑为什么被放在那个对象里,以及其他系统如何取得它的状态。

在自己的项目中,我开始尝试把角色能力、武器逻辑和动画表现拆开,而不是继续堆在同一个蓝图里。

当时对“模块化”的理解当然还比较初级,但这个过程让我开始习惯去问:

这个功能怎么实现?

之外,再多问一句:

这个功能应该放在哪里?

这个思维后来对我继续做更复杂的 UE 项目帮助很大。


最让我印象深刻的问题:两个动画蓝图为什么总差一拍?#

这个项目里真正让我停下来认真 Debug 的,是动画同步问题。

当时第一人称角色的手臂和武器分别有自己的 Animation Blueprint。

最开始我的理解很自然:

Character 负责保存角色状态,比如:

是否瞄准
是否开火
当前移动状态
当前武器状态

然后:

Character
├── 手臂 AnimBP 读取状态
└── 武器 AnimBP 读取状态

两边读取的是同一份 Character 数据。

看起来应该不会有什么问题。

但实际运行以后,手臂和武器有时会出现一帧左右的状态错位。

尤其在 ADS、开火以及动画状态切换时,会出现:

  • 手臂已经进入新的姿态,但武器还停留在上一帧;
  • 武器已经发生状态变化,但手臂还没有同步;
  • 两边单独看都正常,放在一起却会产生明显错位。

一开始我以为是变量更新有问题。

但继续排查以后,我发现真正的问题并不是:

“数据是什么?”

而是:

“谁先计算?”


数据相同,不代表最终结果一定同步#

原来的结构可以理解成:

Character 更新状态
↓
┌──────────────┐
↓ ↓
手臂 AnimBP 武器 AnimBP

两套 Animation Blueprint 都读取 Character 的状态,然后独立完成自己的更新和求值。

问题在于:

如果两套 AnimBP 之间没有明确依赖关系,就不能简单假设它们一定按照固定顺序计算。

某一帧可能出现:

Character 更新
↓
武器 AnimBP 先计算
↓
手臂 AnimBP 后计算

也可能是反过来。

从数据内容来说,两边读取的变量没有错。

但从最终动画结果来说,它们有可能不处于同一个计算阶段。

于是就出现了:

变量值一致,但动画表现依然不同步。

这件事让我第一次比较直观地理解:

同步不仅是“数据一样”,还包括“计算时序一致”。


解决思路:建立明确的动画依赖链#

最后我没有继续给两边增加更多同步变量,而是调整了它们之间的依赖关系。

原来的逻辑是:

┌→ 手臂 AnimBP
Character ──────┤
└→ 武器 AnimBP

也就是两个 AnimBP 并列读取 Character 数据。

修改后变成:

Character
↓
手臂 AnimBP
↓
武器 AnimBP

具体思路是:

  1. Character 首先更新 Gameplay 状态;
  2. 手臂 AnimBP 读取 Character 数据并完成自己的状态更新;
  3. 武器 AnimBP 不再单纯独立读取 Character,而是依赖手臂 AnimBP 已经计算完成的状态;
  4. 通过这种依赖关系,让武器动画始终对齐手臂这一帧的最终结果。

最终数据流变成:

Character 更新 Gameplay 状态
↓
手臂 AnimBP 更新
↓
手臂得到当前帧最终动画状态
↓
武器 AnimBP 再进行更新

这样以后,武器不再有机会“抢在手臂前面”根据不一致的阶段结果进行求值。

之前出现的一帧错位和动画不同步也随之解决。


这个 Bug 让我第一次真正理解“计算时序”#

以前我提到“同步”,最容易想到的是:

A = 100
B = 100

如果两个对象拿到一样的值,那就算同步。

但这个问题让我意识到:

这其实只解决了状态一致性。

还没有解决时序一致性。

比如:

第 N 帧:
A 已经根据新数据完成计算
B 仍然保留上一阶段的计算结果

即使两边最终都会拿到相同的数据,玩家当前这一帧看到的结果依然可能不同。

所以真正可靠的同步应该至少考虑:

状态一致
依赖关系正确
计算顺序正确

这比单纯检查变量有没有成功赋值更重要。


为什么这个问题比“怎么做枪口火焰”更让我记得住#

项目里我拆解并复现了很多具体功能。

比如:

  • Niagara;
  • MetaSound;
  • 后坐力;
  • 换弹;
  • ADS;
  • 弹药 UI;
  • 弹孔;
  • 命中效果。

这些知识当然都有价值。

但如果现在让我回忆这个项目,最先想到的还是动画同步问题。

只停留在实例表面时,过程很容易变成:

看到现成效果
↓
找到对应节点
↓
原样复现
↓
功能成功

而真正的逆向分析与 Debug 是:

观察实例表现
↓
追踪状态来源与调用关系
↓
提出对系统结构的猜测
↓
拆开验证
↓
在自己的工程中复现
↓
表现异常
↓
继续排查计算过程
↓
发现原来的理解不完整
↓
定位真正原因
↓
修改架构或数据流
↓
解决问题

后面这个过程反而更容易让我真正理解一个系统。

所以从这个项目开始,我慢慢产生了一个很明显的感觉:

真正让我学会某个系统的,很多时候不是第一次把它做出来,而是第一次把它修好。


换弹系统也让我开始理解状态流程#

项目后面又实现了相对完整的换弹流程。

从玩家角度看,换弹只是:

按下 R。

但真正做的时候,需要考虑:

  • 当前弹匣还有多少子弹;
  • 当前备弹还有多少;
  • 弹匣容量是多少;
  • 弹匣满时是否允许换弹;
  • 换弹过程中能否再次开火;
  • 连续触发换弹应该如何处理;
  • 动画什么时候开始;
  • 实际弹药什么时候增加;
  • UI 在什么时候刷新。

为了理解实例里的换弹为什么能够稳定工作,我沿着输入事件一路追踪到状态切换、动画播放、弹药提交和 UI 更新,再逐步确认每一个环节发生的时间。

于是一个非常简单的输入行为,逐渐变成了一组状态之间的协调。

这也是我第一次开始明显感受到:

Gameplay 很多时候不是“实现一个动作”,而是管理一组状态之间的转换。


最后的想要做成的成品#

逆向选择的工程是一个基础 FPS Demo。

它没有完整 AI。

没有多人网络。

没有复杂地图。

也没有后来项目里的 GAS、GameFeature、Replication 等系统。

但它已经把 FPS 中最基础的一条链路串了起来:

Input
↓
Character Ability
↓
Animation
↓
Aim
↓
Weapon
↓
Fire
↓
Trace
↓
Hit Feedback
↓
Ammo
↓
Reload
↓
UI

从结果来看,它只是一个基于实例逆向复现的基础项目。

但从学习过程来看,它让我第一次比较完整地接触了:

  • FPS Gameplay;
  • Animation Blueprint;
  • 多 AnimBP 协作;
  • 动画求值时序;
  • Gameplay 模块化;
  • ADS;
  • Recoil;
  • Niagara;
  • MetaSound;
  • Hit Feedback;
  • Reload;
  • Weapon UI;
  • Debug;
  • 对实例项目的数据流与依赖关系进行逆向分析。

回头看这个项目#

现在再来看这个 FPS Demo,它的复杂度当然已经不高。

很多实现方式现在重新做,我也不会再完全沿用。

但它依然是我学习 UE 过程中很重要的一步。

之前做第三人称闯关项目的时候,我第一次感受到的是:

我可以把几个基础系统组合成一个完整的游戏流程。

而到了这个 FPS 项目,我第一次开始进一步意识到:

功能能跑并不代表系统就是正确的,还要理解状态、依赖与计算时序。

后来继续做更复杂的 UE 项目以后,我开始接触:

  • GAS;
  • Lyra;
  • GameFeature;
  • DataAsset;
  • GameplayTag;
  • GameplayMessage;
  • Multiplayer Replication;
  • 武器生命周期;
  • Hero Loadout;
  • Respawn 重建;
  • 服务器权威状态。

这些系统看起来和当初那个“一帧动画不同步”完全不是一个量级。

但现在回头看,它们其实一直在反复问同样的问题:

谁拥有这份状态?

谁负责修改它?

谁依赖它?

谁先计算?

数据在什么时间被消费?

有没有可能读到上一阶段的结果?

所以对我来说,这个 FPS Demo 真正留下来的并不是“我学会了怎么做一把枪”。

而是:

我第一次开始尝试理解 Unreal Engine 为什么会按照这样的方式运行。

FPS僵尸,但是腿呢,可惜没有动画遮罩
https://iceneoning-blog.pages.dev/posts/fps僵尸但是腿呢可惜没有动画遮罩/
作者
冰霓Iceneon
发布于
2026-08-31
许可协议
CC BY-NC-SA 4.0