☰
Godot中复刻ALS高级运动系统:动画驱动位移与三层状态机实战
2026/10/2 16:00:09 网站建设 项目流程

1. 为什么我要在Godot里死磕ALS复刻

第一次在虚幻引擎里跑起ALS(Advanced Locomotion System)的时候,我盯着那个角色从走路切到小跑、再切到冲刺,脚掌落地的节奏跟速度完全对得上,转身的时候上半身和下半身还能分开转,当时就觉得这套东西做得是真讲究。后来因为项目原因转到Godot,第一件事就是想找一套对标的 locomotion 方案,翻了一圈发现要么是简单的八向移动,要么是状态机写得比较糙,能做到ALS那种“动画驱动位移”的,几乎没有。

这就是这个开源项目的起点:把ALS那套运动逻辑,在Godot里从头实现一遍,做到行为层面的100%复刻。注意我说的是行为复刻,不是把虚幻的蓝图一行行翻译成GDScript——引擎底层不一样,硬翻译只会得到一堆跑不起来的代码。我要的是:同样的输入,角色做出同样的反应,动画切换的时机、根运动的应用方式、速度分级的阈值,全部对齐。

这套东西适合谁?如果你正在用Godot做第三人称动作游戏、射击游戏,或者任何需要角色“跑起来像个人”的项目,那这套逻辑你直接拿去改就能用。如果你只是想学Godot的动画状态机和根运动怎么配合,这里面的拆解也够你啃一阵子。前置要求不高,会写GDScript、看得懂AnimationTree的基本节点就行,但想完全吃透,最好对状态机和向量运算有点概念。

项目已经开源,代码结构我尽量做得扁平,注释写得比较密,就是为了方便你对着改。下面我按“为什么这么设计—核心细节怎么落地—完整实操流程—踩过的坑”这个顺序,把整套东西掰开讲。

2. 整体架构设计与方案选型思路

2.1 为什么不用现成的CharacterBody3D模板硬改

Godot自带的CharacterBody3D模板给的是一个最基础的移动框架:读输入、算方向、move_and_slide。很多人做locomotion就是在这个基础上加个AnimationTree,然后根据速度切动画。这么做能跑,但做不出ALS的味道,问题出在驱动方向反了。

传统做法是“位移驱动动画”:我先算出角色这一帧移动了多少,然后根据速度大小去选一个动画播放。ALS的思路是反过来的,叫“动画驱动位移”:动画本身携带根运动(root motion),角色的实际位移由动画的根运动决定,速度分级只是决定“当前该播哪一段动画”。这么做的最大好处是脚底不打滑——因为位移和脚步是同一套数据出来的,天然同步。

所以架构上我做了两个关键决策。第一,位移完全交给根运动,代码里不再手动给velocity赋值做水平移动,只保留重力。第二,速度分级用动画曲线驱动,而不是用if-else硬切。具体来说,我用一个BlendSpace1D把idle、walk、run、sprint四段动画按速度混合,混合的参数不是直接的速度值,而是经过归一化处理的“速度比例”。

2.2 状态机的分层设计

ALS原版的状态机分了好几层,我这边简化成三层,够用且好维护。

最底层是Locomotion状态层,管的是地面移动:idle、walk、run、sprint这几个基础状态,以及它们之间的过渡。这一层用AnimationNodeStateMachine实现,每个状态是一个BlendSpace1D。

中间层是Action状态层,管的是跳跃、落地、翻滚这类会打断locomotion的动作。这一层我用AnimationNodeStateMachine嵌套在Locomotion层之上,通过travel()切换。关键是过渡条件要写清楚:比如跳跃只能在grounded状态下触发,落地动画播完必须回到locomotion。

最上层是Overlay层,管的是上半身和下半身的分离。比如角色在跑动的时候上半身转向瞄准方向,下半身还在往前跑。这一层用AnimationNodeBlend做骨骼遮罩,把上半身和下半身的动画分开控制。

三层之间通过信号通信,不直接互相引用,这样改一层不会把另外两层搞崩。我试过把三层揉成一个巨大的状态机,改到后面自己都看不懂连线了,分层是血泪教训。

2.3 根运动与代码位移的取舍

这里有个坑必须提前说:Godot的根运动默认是关的,你得在AnimationTree上把root_motion_track指向根骨骼的位移轨道,然后在AnimationPlayer里确保动画本身带了位移数据。很多免费动画资源是不带根运动的,你得自己用Blender或者动捕数据补上,或者退而求其次用代码模拟根运动。

我的方案是混合制:优先用根运动,如果检测到当前动画没有根运动数据,自动降级到代码位移。降级的判断逻辑写在AnimationTree的animation_finished信号回调里,检查根运动轨道是否为空。这样即使你手头的动画资源不齐,项目也能跑起来,只是脚底可能会有点滑,等补齐根运动就好了。

3. 核心细节拆解与实操要点

3.1 速度分级与BlendSpace1D的参数设置

ALS的速度分级大概是:idle(0)、walk(1.5 m/s)、run(3.5 m/s)、sprint(6 m/s)。这个数值不是拍脑袋定的,是根据角色模型的实际步幅和动画播放速度反推出来的。具体怎么算:假设你的walk动画一个循环是1秒,角色在这个循环里根运动前进了1.5米,那walk对应的速度就是1.5 m/s。run和sprint同理。

在BlendSpace1D里,我把这四个动画按上面的速度值摆在数轴上。但混合参数不能直接用速度,因为速度是无上限的,而BlendSpace1D的混合范围是0到1。所以我加了一个归一化步骤:

func _get_blend_position(speed: float) -> float: var max_speed := sprint_speed # 6.0 return clamp(speed / max_speed, 0.0, 1.0)

这样速度6 m/s对应混合位置1.0,速度3 m/s对应0.5,落在run和walk之间,BlendSpace1D会自动做插值混合。实测下来,这样混合出来的过渡比硬切自然得多,尤其是从run减速到walk的时候,不会出现动画突然跳一下的情况。

注意:BlendSpace1D的混合模式要选Interpolated,不要选Discrete,否则就是硬切了。另外每个动画的循环模式要设成Loop,不然播完会停住。

3.2 根运动的提取与重定向

根运动的数据在动画里是存在根骨骼的位移轨道上的。Godot的AnimationTree有个root_motion_track属性,你把它指向根骨骼的position轨道,引擎就会在每帧把根运动的位移和旋转提取出来,你可以通过get_root_motion_position()和get_root_motion_rotation()拿到。

但这里有个细节:根运动提取出来的位移是相对于角色本地坐标系的,你得把它转换到世界坐标系才能用。转换的代码大概是这样:

var root_motion := animation_tree.get_root_motion_position() var world_motion := global_transform.basis * root_motion velocity.x = world_motion.x / delta velocity.z = world_motion.z / delta

注意除以delta,因为根运动给的是这一帧的位移量,要转成速度才能喂给move_and_slide。我一开始忘了除delta,角色跑得跟蜗牛一样,排查了半天才发现是单位问题。

还有一个坑:根运动的旋转。如果你的动画里角色有转身,根运动也会带旋转。这时候你不能直接把旋转应用到角色上,否则会和你的输入转向打架。我的做法是只取根运动的位移,旋转交给代码处理,这样转向手感更可控。

3.3 上下半身分离的骨骼遮罩配置

上下半身分离是ALS的一个标志性特性。实现方式是在AnimationTree里加一个AnimationNodeBlend节点,把两个动画源混合,混合权重由骨骼遮罩控制。

具体配置步骤:在AnimationTree里新建一个AnimationNodeBlend,给它两个输入端口。第一个端口接下半身的locomotion动画,第二个端口接上半身的动作动画(比如瞄准、射击)。然后给这个Blend节点设置blend_amount,0是全下半身,1是全上半身。

关键是骨骼遮罩。你需要在Skeleton3D节点上创建一个Skin,把上半身的骨骼(脊椎以上)标记出来,然后在AnimationNodeBlend的blend_point里引用这个Skin。这样混合的时候,只有上半身的骨骼会受第二个端口影响,下半身保持第一个端口的动画。

我试过用AnimationNodeBlend2做这个,效果不如AnimationNodeBlend精细,因为Blend2是整个动画混合,没法按骨骼遮罩。所以如果你要做上下半身分离,老老实实用AnimationNodeBlend。

提示:骨骼遮罩的创建在Skeleton3D的Inspector里,点“Create Skin”然后勾选上半身骨骼。别忘了给下半身也创建一个反向的Skin,有些混合场景需要用到。

3.4 状态过渡的条件与优先级

状态过渡最容易出问题的地方是过渡条件冲突。比如角色正在从run减速到walk,同时玩家按了跳跃,这时候是该先播减速过渡还是直接切跳跃?我的处理原则是:Action层优先级高于Locomotion层,但Action层不能无条件打断Locomotion,得看当前Locomotion状态是否允许被打断。

具体来说,我给每个Locomotion状态加了一个can_be_interrupted标志。idle和walk可以随时被打断,run和sprint在过渡过程中(比如run到walk的减速阶段)不允许被打断,得等过渡完成。这个标志通过状态机的transition_request信号来检查,代码大概是这样:

func _on_transition_request(from_state: String, to_state: String) -> void: if from_state in ["run", "sprint"] and is_transitioning: return # 拒绝打断 state_machine.travel(to_state)

这样处理之后,跳跃的手感明显好了很多,不会出现按了跳跃角色还在滑行的情况。

4. 完整实操流程与关键环节实现

4.1 项目结构与节点树搭建

先把节点树搭起来,这是后面所有逻辑的骨架。我的节点树是这样的:

Player (CharacterBody3D) ├── CollisionShape3D ├── Skeleton3D │ └── (骨骼层级) ├── AnimationPlayer ├── AnimationTree └── CameraRig (Node3D) └── SpringArm3D └── Camera3D

AnimationTree的tree_root是一个AnimationNodeStateMachine,里面嵌套了前面说的三层结构。AnimationPlayer里放所有动画资源,每个动画的根骨骼位移轨道要确保存在。

搭建顺序建议:先放CharacterBody3D和CollisionShape3D,确保角色能站在地面上;再加Skeleton3D和AnimationPlayer,导入动画;最后加AnimationTree,配置状态机。这个顺序别乱,不然调试的时候你会分不清是碰撞问题还是动画问题。

4.2 输入映射与移动向量计算

输入映射在项目设置里配,我用了四个动作:move_forward、move_back、move_left、move_right,分别绑到WASD。另外加了jump绑空格,sprint绑Shift。

移动向量的计算要考虑相机方向,因为第三人称游戏里“前”是相对于相机的。代码:

func _get_movement_input() -> Vector2: var input := Vector2.ZERO input.x = Input.get_axis("move_left", "move_right") input.y = Input.get_axis("move_forward", "move_back") return input.limit_length(1.0) func _get_movement_direction(input: Vector2) -> Vector3: var camera_basis := camera_rig.global_transform.basis var forward := -camera_basis.z var right := camera_basis.x forward.y = 0 right.y = 0 forward = forward.normalized() right = right.normalized() return (forward * input.y + right * input.x).normalized()

注意limit_length(1.0),这是为了防止斜向移动比直线移动快。很多新手会漏掉这一步,导致角色斜着走速度是直线的1.4倍。

4.3 速度状态更新与动画参数同步

每帧的更新逻辑分三步:读输入算目标速度、根据当前速度决定状态、把速度喂给AnimationTree。

func _physics_process(delta: float) -> void: var input := _get_movement_input() var direction := _get_movement_direction(input) # 目标速度 var target_speed := 0.0 if input.length() > 0.1: target_speed = sprint_speed if Input.is_action_pressed("sprint") else run_speed # 平滑加速 current_speed = move_toward(current_speed, target_speed, acceleration * delta) # 更新动画参数 animation_tree.set("parameters/locomotion/blend_position", _get_blend_position(current_speed)) animation_tree.set("parameters/locomotion/direction", direction) # 应用根运动 _apply_root_motion(delta) # 重力 if not is_on_floor(): velocity.y -= gravity * delta else: velocity.y = 0 move_and_slide()

这里current_speed是标量,只表示速度大小,方向由direction单独控制。这样做的原因是BlendSpace1D只需要速度大小来混合动画,方向用来控制角色的朝向。

4.4 根运动的应用与位移同步

根运动的应用是整套逻辑里最需要小心的地方。前面说了,根运动给的是本地坐标系的位移,要转到世界坐标系。但还有一个问题:根运动的位移是动画播放出来的,而动画的播放速度受AnimationTree的process_callback影响。如果process_callback设成Physics,根运动就和物理帧同步;如果设成Idle,就和渲染帧同步。我建议设成Physics,这样位移和碰撞检测在同一帧里处理,不会出现穿墙。

func _apply_root_motion(delta: float) -> void: var root_motion := animation_tree.get_root_motion_position() if root_motion.length_squared() < 0.0001: # 没有根运动数据,降级到代码位移 var fallback := _get_movement_direction(_get_movement_input()) * current_speed velocity.x = fallback.x velocity.z = fallback.z return var world_motion := global_transform.basis * root_motion velocity.x = world_motion.x / delta velocity.z = world_motion.z / delta

降级逻辑很重要,因为不是所有动画都有根运动。我手头有几段免费动画就没有,降级之后至少角色能动,只是脚底会滑,等换了带根运动的动画就好了。

4.5 跳跃与落地的状态切换

跳跃的实现分三段:起跳、空中、落地。起跳时切到jump_start动画,空中切到jump_loop,落地切到land。关键是过渡时机。

func _on_jump() -> void: if not is_on_floor(): return velocity.y = jump_velocity state_machine.travel("jump_start") jump_started.emit() func _on_land() -> void: state_machine.travel("land") land_timer.start(land_duration)

land动画播完之后要回到locomotion,这个用AnimationTree的animation_finished信号处理。注意land动画的时长要和land_timer对齐,不然会出现动画还没播完就切回locomotion的情况,看起来会很突兀。

注意:跳跃的jump_velocity要根据重力算,公式是v = sqrt(2 * g * h),h是你想要的跳跃高度。比如重力20,想跳1.5米高,v就是sqrt(60)≈7.75。这个数值我调了好几次才找到手感合适的。

5. 常见问题与排查技巧实录

5.1 角色脚底打滑怎么排查

脚底打滑是根运动项目最常见的问题,原因通常有三个。第一,动画本身没有根运动数据,降级到代码位移之后速度对不上。排查方法:在_apply_root_motion里打印root_motion.length(),如果是0就是没数据。第二,根运动的速度和动画播放速度不匹配。比如动画是1倍速播放,但你代码里把current_speed设成了动画速度的1.5倍,那肯定滑。排查方法:对比current_speed和根运动算出来的速度,两者应该接近。第三,AnimationTree的process_callback设成了Idle,导致根运动和物理帧不同步。改成Physics试试。

我遇到过一次特别隐蔽的打滑:动画的根骨骼位移轨道上有关键帧,但关键帧的插值模式是Constant,导致根运动是阶梯状的,角色一卡一卡的。把插值改成Linear就好了。这个坑排查了一下午,因为从数据上看根运动是有的,但就是不平滑。

5.2 动画过渡生硬的调整方法

过渡生硬通常是BlendSpace1D的混合曲线没调好。Godot的BlendSpace1D默认是线性混合,但实际动画的过渡往往需要非线性。你可以在BlendSpace1D的每个动画点上右键,调整blend_point的min和max值,控制混合的起止范围。

另一个原因是过渡时间太短。AnimationNodeStateMachine的每个过渡都有一个xfade_time参数,默认是0,也就是硬切。把它设成0.2到0.3秒,过渡会自然很多。但也不能太长,超过0.5秒会感觉角色反应迟钝。

我个人的经验值:idle到walk用0.3秒,walk到run用0.25秒,run到sprint用0.2秒。速度越快,过渡时间越短,因为高速运动下玩家对延迟更敏感。

5.3 上下半身分离时骨骼错位

骨骼错位一般是遮罩没配对。检查两个地方:第一,AnimationNodeBlend的blend_point引用的Skin是否正确,上半身的Skin要包含脊椎以上的所有骨骼,包括手臂和头。第二,两个输入动画的骨骼命名要一致,如果一个是Mixamo的命名,一个是自己绑的,骨骼对不上就会错位。

还有一个容易忽略的点:AnimationNodeBlend的blend_amount在0和1之间切换时,中间状态是混合的,如果两个动画的骨骼姿势差异太大,中间状态会很奇怪。解决办法是加一个过渡动画,或者把blend_amount的变化速度调快,让混合过程尽量短。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
角色脚底打滑根运动缺失或速度不匹配打印根运动长度和当前速度补齐根运动或调整速度参数
动画过渡生硬过渡时间太短或混合曲线不对检查xfade_time和blend_point调整过渡时间到0.2-0.3秒
上下半身错位骨骼遮罩配置错误检查Skin的骨骼包含范围重新创建Skin并核对骨骼命名
跳跃后不落地落地检测条件不对检查is_on_floor()的调用时机在physics_process里检测
斜向移动过快输入向量没归一化检查limit_length(1.0)加上归一化处理
根运动方向反了坐标系转换错误打印world_motion的值检查global_transform.basis的乘法顺序

5.5 性能优化的几个实操技巧

AnimationTree的更新是有开销的,尤其是骨骼多、动画多的时候。我做了三个优化。第一,把AnimationTree的active属性在角色远离相机时设成false,近处再打开。第二,BlendSpace1D里的动画数量控制在4个以内,多了混合计算量会上去。第三,骨骼遮罩的Skin不要包含不必要的骨骼,只勾选真正需要分离的部分。

还有一个技巧:把AnimationPlayer的playback_process_mode设成Physics,和AnimationTree的process_callback保持一致,这样动画更新和物理更新在同一帧,不会出现一帧的延迟。这个延迟在高速运动下很明显,角色会看起来像在“追”自己的碰撞体。

6. 开源代码结构与二次开发建议

6.1 代码目录说明

项目开源在GitHub上,目录结构是这样的:

/addons/als_locomotion/ ├── scripts/ │ ├── locomotion_state_machine.gd │ ├── root_motion_applier.gd │ └── input_handler.gd ├── resources/ │ ├── blend_spaces/ │ └── state_machines/ └── scenes/ └── player.tscn

locomotion_state_machine.gd是核心,管状态切换和过渡条件。root_motion_applier.gd管根运动的提取和应用。input_handler.gd管输入映射和向量计算。三个脚本通过信号通信,耦合度很低,你可以单独替换其中一个而不影响另外两个。

6.2 二次开发的扩展点

如果你想在这套基础上加东西,我预留了几个扩展点。第一,locomotion_state_machine.gd里有一个custom_transition_check虚函数,你可以重写它来加自己的过渡条件,比如体力值不够时不能sprint。第二,root_motion_applier.gd里有一个motion_modifier回调,你可以在根运动应用之前修改位移,比如加一个击退效果。第三,input_handler.gd里的输入映射是数据驱动的,你可以在资源文件里加新的输入动作,不用改代码。

我试过用这套框架做一个带攀爬的角色,扩展点在custom_transition_check里加了“靠近墙壁且按攀爬键”的条件,然后在状态机里加了一个climb状态,半天就搞定了。框架的扩展性还是可以的。

6.3 动画资源的替换与适配

替换动画资源的时候,最关键的是根运动轨道的命名。不同来源的动画,根骨骼的名字可能不一样,有的是Root,有的是Hips,有的是Armature。你需要在AnimationTree的root_motion_track里指向正确的骨骼。如果动画没有根运动,就用前面说的降级方案。

另外,动画的帧率也要注意。如果原动画是30帧,你的项目是60帧,根运动的速度会不对。解决办法是在导入动画的时候把帧率统一,或者在代码里根据帧率做补偿。我一般是在Blender里导出的时候就统一成60帧,省得后面麻烦。

7. 我在实际复刻过程中踩过的坑

第一个坑是根运动和代码位移的冲突。一开始我没做降级判断,结果有些动画有根运动、有些没有,有根运动的时候角色正常跑,没根运动的时候角色原地踏步。后来加了降级判断才解决。这个坑的教训是:不要假设所有动画资源都是完整的,一定要做容错。

第二个坑是状态机的过渡条件写得太死。我一开始把过渡条件写成“速度大于3.5就切run”,结果角色在3.5附近反复横跳,动画一直在run和walk之间闪。后来加了迟滞区间,速度大于3.5切run,小于3.2才切回walk,问题就没了。迟滞区间的大小大概是0.3 m/s,太小了还是会闪,太大了反应迟钝。

第三个坑是骨骼遮罩的坐标系问题。我一开始以为骨骼遮罩是按世界坐标系的Y轴来分的,结果发现是按骨骼层级的。上半身的骨骼要从脊椎开始往上选,不能只看Y坐标。这个坑让我调了一整天的遮罩,最后是看了Godot的文档才搞明白。

第四个坑是AnimationTree的process_callback。默认是Idle,我一开始没改,结果根运动和物理帧不同步,角色在斜坡上会抖。改成Physics之后就好了。这个坑比较隐蔽,因为从表现上看只是“有点抖”,不容易联想到是帧同步问题。

第五个坑是输入向量的归一化。前面提过,斜向移动会快1.4倍。我一开始没注意,直到测试的时候发现角色斜着跑比直着跑快,才回头加上limit_length(1.0)。这个坑很基础,但确实容易忘。

这套东西我断断续续做了大概三周,中间推翻重来了两次。第一次是用代码位移驱动动画,做出来脚底滑得没法看。第二次是状态机揉成一团,改到后面自己都理不清。第三次才定下现在这个架构,根运动驱动、三层状态机、骨骼遮罩分离。现在跑起来的效果,跟ALS原版对比,行为层面基本一致,动画过渡的时机和速度分级的阈值都对齐了。

代码已经开源,你可以直接clone下来跑。如果你的动画资源跟我的不一样,可能需要调一下速度阈值和过渡时间,但整体逻辑不用动。后面我打算把攀爬和游泳也加进去,扩展点已经留好了,有兴趣的话可以一起搞。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询