尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Godot复刻虚幻ALS:第三人称角色移动系统完整实现

Godot复刻虚幻ALS:第三人称角色移动系统完整实现 1. 为什么我要在Godot里复刻ALS第一次在虚幻引擎里跑起ALSAdvanced Locomotion System的时候我盯着那个角色从走路切到跑步、从跑步急停到刹车、翻越障碍物时手部自动对齐边缘的整套动作心里就一个念头这套东西要是能搬到Godot里该多好。ALS在虚幻社区里几乎是第三人称角色移动的标杆级存在它把移动状态机、动画蓝图、根骨骼运动、运动匹配这些概念揉成了一套完整可用的方案。但虚幻的项目体量摆在那里一个空项目加上ALS动辄几个G迭代一次着色器就要等半天对于独立开发者或者小团队来说这个门槛不算低。Godot这几年的发展大家有目共睹4.x版本之后渲染和物理都有了质的提升GDScript写起来也顺手开源免费没有授权顾虑。但Godot生态里一直缺一套能和ALS对标的第三人称移动系统。市面上的资源要么是简单的状态机加混合树要么是只做了上半身IK真正把移动、动画、物理、相机这几块打通并且调到手感顺滑的几乎没有。这就是我动手复刻的出发点不是做一个玩具demo而是把ALS那套设计思路完整地翻译到Godot的技术栈里让用Godot的人也能直接拿到一套能用的第三人称移动方案。这个项目已经开源代码结构尽量保持了模块化和可读性。它适合谁呢如果你正在用Godot做第三人称动作游戏、冒险游戏或者任何需要角色流畅移动的项目这套东西可以直接拿来当基础。如果你只是想学习状态机和动画树怎么配合里面的分层设计和过渡逻辑也值得一看。哪怕你之前没接触过ALS只要对Godot的节点系统有基本了解跟着代码读一遍也能理清思路。2. ALS的核心设计思路与Godot的适配拆解2.1 ALS到底解决了什么问题很多人第一次看ALS的演示注意力都在那些翻越、攀爬的炫酷动作上但ALS真正的价值在于它把角色移动拆成了一套可组合的状态系统。传统做法是写一个巨大的状态机走路一个状态、跑步一个状态、跳跃一个状态状态之间硬编码切换条件。这种写法在状态少的时候没问题一旦要加急停、加转向、加不同速度的过渡代码就会变成一团乱麻。ALS的思路不一样。它把移动拆成几个正交的维度速度从静止到冲刺、方向前后左右以及任意角度、姿态站立、蹲伏、下落。这几个维度组合起来形成当前的运动状态动画层根据这个状态去混合对应的动画。比如“以中等速度向左前方移动同时处于下落姿态”这个组合在传统状态机里可能要写一个专门的状态但在ALS的框架里只是几个参数的组合结果。虚幻里这套逻辑主要靠动画蓝图和状态机实现动画蓝图负责根据角色速度、加速度、是否着地这些变量去选择播放哪段动画混合空间负责在多个动画之间做插值。根骨骼运动让动画本身驱动角色的位移这样脚步和地面不会打滑。运动匹配则是在大量动画片段里根据当前速度向量找到最合适的片段来播放。2.2 为什么Godot需要另一套实现方式Godot没有虚幻那套动画蓝图系统。Godot的AnimationTree提供了状态机和混合空间节点但它的工作方式和虚幻有本质区别。虚幻的动画蓝图是可视化的节点图逻辑和动画混在一起Godot的AnimationTree更偏向于一个纯粹的动画播放控制器逻辑应该放在脚本里。所以我在复刻的时候做了一个关键决策把状态逻辑和动画表现彻底分开。角色的移动状态、速度计算、着地检测这些全部用GDScript在CharacterBody3D的脚本里处理AnimationTree只负责接收参数然后播放对应的动画。这样做的好处是逻辑清晰调试的时候可以直接打印变量看状态对不对不用在节点图里到处找线。另一个决策是关于根骨骼运动的处理。虚幻里根骨骼运动是动画系统原生支持的Godot的AnimationTree也支持根骨骼运动但需要手动开启并且处理起来有一些坑。我的做法是在动画导入时保留根骨骼位移信息然后在代码里根据当前状态决定是否应用根骨骼运动。比如跑步和走路用根骨骼运动来驱动位移但急停的时候根骨骼运动要关掉改由代码控制减速否则角色会滑出去。2.3 整体架构的分层设计整个项目我分成了四层。最底层是输入层负责读取玩家的摇杆和按键输入做死区处理和归一化。这一层不涉及任何游戏逻辑只是把原始输入转成标准化的向量。往上是运动逻辑层这是核心。它接收输入向量结合当前速度、加速度、摩擦力计算出这一帧角色应该有的速度和位移。同时处理重力、着地检测、台阶检测、斜坡滑动这些物理相关的计算。这一层完全不碰动画只输出几个关键参数当前速度大小、速度方向相对于角色朝向的角度、是否着地、是否在下落。再往上是状态层它根据运动逻辑层输出的参数判断当前应该处于哪个移动状态。比如速度接近零就是待机速度在走路范围内就是走路超过跑步阈值就是跑步离地就是下落。状态切换的时候会触发过渡逻辑比如从跑步到待机不是瞬间切换而是先经过一个减速过渡。最上层是动画层AnimationTree根据状态层传来的参数去混合和播放动画。这一层用到了Godot的BlendSpace2D来做八方向移动混合用StateMachine来处理待机、移动、跳跃、下落这些大状态之间的切换用BlendTree来组合上下半身的动画。3. 核心模块的细节实现与实操要点3.1 角色控制器的参数调校CharacterBody3D是Godot里做角色移动的基础节点但它的默认参数直接拿来用的话手感会很差。我在项目里暴露了一组参数供调整这里说几个关键的。最大速度我设了三档走路速度1.5米每秒跑步速度4.5米每秒冲刺速度7米每秒。这个数值参考了ALS的默认配置也符合大多数第三人称游戏的手感。加速度方面走路加速度给到10跑步给到15冲刺给到20。加速度不能太低否则角色起步会感觉拖沓也不能太高否则瞬间就到最大速度没有加速的过程感。摩擦力我分成了地面摩擦和空中摩擦。地面摩擦给到12这个值决定了松开摇杆后角色减速的快慢。空中摩擦给到0.5让角色在空中保持惯性不会因为松开方向键就垂直下落。重力我用了Godot默认的9.8乘以一个系数实际给到24左右这样跳跃下落的速度感比较接近主流动作游戏。注意这些参数不是固定的需要根据你的角色模型比例和动画节奏来微调。比如角色如果比标准人形高很多速度值要相应放大否则看起来像慢动作。转向速度也是一个容易被忽略的参数。我用了lerp_angle来做朝向插值转向速度给到12。这个值太低角色转身会拖泥带水太高会显得僵硬。实际调的时候可以找一个参照物让角色从静止开始向侧面移动观察转身过程是否自然。3.2 动画状态机的搭建逻辑AnimationTree里的StateMachine我建了五个大状态Idle、Locomotion、Jump、Fall、Land。每个大状态下面再用BlendTree或者BlendSpace来组织具体动画。Locomotion状态是最复杂的它里面放了一个BlendSpace2D。BlendSpace2D的横轴是角色速度在朝向方向上的分量纵轴是速度在侧向上的分量。这样当角色向前走的时候混合空间的点落在正上方区域播放向前走的动画向左平移的时候落在左侧区域播放侧步动画。八个方向的动画我都放了对应的片段进去混合空间会自动在它们之间插值。这里有个细节BlendSpace2D的坐标范围我设成了-1到1但实际速度值可能是4.5或者7。所以在把速度传给BlendSpace之前我做了一次归一化把速度除以最大速度映射到-1到1的区间。这样不管最大速度怎么调混合空间都不用改。Jump和Fall状态比较简单各自播放对应的动画。Land状态是一个过渡状态播放落地缓冲的动画播完自动切回Locomotion或者Idle。状态之间的切换条件我写在了AnimationTree的Transition里用表达式来判断。比如从Fall到Land的条件是is_on_floor为true从Land到Locomotion的条件是动画播放进度超过0.8。3.3 根骨骼运动的开启与关闭时机根骨骼运动是让动画驱动位移的关键。在Godot里开启根骨骼运动需要在导入动画的时候勾选Root Motion Track然后在AnimationTree的根节点上启用Root Motion。但根骨骼运动不是一直开着的。我的处理逻辑是这样的当角色处于Locomotion状态并且在地面上时开启根骨骼运动让动画里的脚步位移直接作用到角色身上。当角色处于Jump、Fall、Land状态时关闭根骨骼运动位移完全由代码控制。当角色从跑步急停到待机时根骨骼运动也要关掉因为急停动画里的根骨骼位移是向前的如果开着角色会继续往前滑。实操心得根骨骼运动和代码位移同时生效的时候角色会走两倍距离。调试的时候可以先把根骨骼运动关掉确认代码位移没问题了再打开对比一下两者的差异。还有一个坑是根骨骼运动的坐标系。Godot的根骨骼运动默认是在角色本地空间里的如果角色旋转了根骨骼位移的方向也会跟着转。这通常是想要的效果但如果你发现角色移动方向不对检查一下根骨骼运动的轴向设置。3.4 上下半身动画的分层混合第三人称游戏里经常需要上半身和下半身播放不同的动画。比如角色在跑步的时候上半身转向瞄准方向下半身继续朝移动方向跑。Godot的AnimationTree支持用BlendTree做骨骼遮罩但配置起来比虚幻麻烦一些。我的做法是在AnimationTree里建两个BlendTree节点一个负责下半身一个负责上半身然后用Blend2节点按骨骼遮罩混合。骨骼遮罩需要在Skeleton节点上配置把上半身和下半身的骨骼分别归到不同的遮罩组里。具体操作是在Skeleton的Bone Map里给每根骨骼指定一个遮罩名称比如spine以上的骨骼都归到upper_body组spine以下的归到lower_body组。然后在AnimationTree的Blend2节点里选择对应的遮罩。这样下半身的BlendSpace2D播放移动动画上半身的StateMachine播放瞄准或者攻击动画两者互不干扰。注意骨骼遮罩的过渡需要时间不能瞬间切换。我在Blend2节点上设了0.2秒的混合时间让上下半身的动画过渡自然一些。4. 实操过程与核心环节的完整实现4.1 项目结构与文件组织先把项目结构理清楚后面找东西才不会乱。我的目录结构是这样的res:// ├── scenes/ │ ├── player/ │ │ ├── player.tscn # 玩家场景 │ │ └── player.gd # 玩家控制脚本 │ └── levels/ │ └── test_level.tscn # 测试关卡 ├── animations/ │ ├── locomotion/ # 移动动画 │ ├── jump/ # 跳跃动画 │ └── fall/ # 下落动画 ├── resources/ │ └── animation_tree.tres # AnimationTree资源 └── scripts/ ├── state_machine.gd # 状态机基类 └── animation_controller.gd # 动画控制器player.tscn的结构是CharacterBody3D作为根节点下面挂一个CollisionShape3D做碰撞体一个Skeleton3D加MeshInstance3D做角色模型一个AnimationPlayer挂动画一个AnimationTree挂动画树一个SpringArm3D加Camera3D做第三人称相机。4.2 输入映射与死区处理输入映射在项目设置里配置我定义了move_forward、move_backward、move_left、move_right、jump、sprint这几个动作。摇杆输入用Input.get_vector来获取它自带死区处理但默认死区是0.5对于精细操作来说太大了。我在项目设置里把死区调到0.2这样轻微推摇杆也能有反应。func _get_input_direction() - Vector2: var input_dir Input.get_vector(move_left, move_right, move_forward, move_backward) if input_dir.length() 0.2: return Vector2.ZERO return input_dir.normalized()这段代码先获取输入向量如果长度小于0.2就当作没有输入否则归一化后返回。归一化是为了保证斜向移动的速度和直线移动一样不然斜着走会快1.414倍。4.3 速度计算与位移应用速度计算是每帧在_physics_process里做的。先根据输入方向算出目标速度然后用move_toward把当前速度向目标速度插值。func _update_velocity(delta: float) - void: var input_dir _get_input_direction() var target_speed _get_target_speed() if input_dir ! Vector2.ZERO: var direction (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() var target_velocity direction * target_speed velocity.x move_toward(velocity.x, target_velocity.x, acceleration * delta) velocity.z move_toward(velocity.z, target_velocity.z, acceleration * delta) else: velocity.x move_toward(velocity.x, 0, friction * delta) velocity.z move_toward(velocity.z, 0, friction * delta) if not is_on_floor(): velocity.y - gravity * delta else: velocity.y 0_get_target_speed根据是否按下冲刺键返回走路、跑步或冲刺速度。move_toward是Godot内置的插值函数它保证每次移动不超过给定的步长这样加速和减速都是线性的手感比较可控。位移应用就是调用move_and_slide()但要注意根骨骼运动的处理。如果当前状态开启了根骨骼运动就不应该再应用代码计算的水平位移否则会叠加。4.4 动画参数的传递与同步动画控制器每帧从玩家脚本读取状态参数然后设置到AnimationTree上。我用了几个关键参数speed当前速度大小、direction速度方向相对于角色朝向的角度、is_on_floor、is_jumping、is_falling。func _update_animation_tree() - void: var speed Vector2(velocity.x, velocity.z).length() var local_velocity global_transform.basis.inverse() * velocity var direction Vector2(local_velocity.x, local_velocity.z) animation_tree.set(parameters/Locomotion/blend_position, direction / max_speed) animation_tree.set(parameters/conditions/is_on_floor, is_on_floor()) animation_tree.set(parameters/conditions/is_falling, velocity.y -0.1 and not is_on_floor())这里把速度转换到角色本地空间这样direction的x分量表示左右速度y分量表示前后速度。除以max_speed做归一化后传给BlendSpace2D的blend_position。4.5 相机跟随与视角控制第三人称相机用SpringArm3D加Camera3D实现。SpringArm3D负责碰撞检测防止相机穿墙。相机控制逻辑放在_Input或者_unhandled_input里处理鼠标移动。func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseMotion and Input.mouse_mode Input.MOUSE_MODE_CAPTURED: var mouse_delta event.relative camera_pivot.rotate_y(-mouse_delta.x * mouse_sensitivity) spring_arm.rotate_x(-mouse_delta.y * mouse_sensitivity) spring_arm.rotation.x clamp(spring_arm.rotation.x, -PI/3, PI/6)camera_pivot是SpringArm3D的父节点负责水平旋转。spring_arm负责垂直旋转用clamp限制角度范围防止相机翻转。鼠标灵敏度我设的是0.002这个值需要根据个人习惯调整。实操心得相机跟随角色移动的时候不要用lerp硬跟会有延迟感。我的做法是相机位置直接跟随角色骨骼上的一个挂点旋转用lerp插值这样既有跟随感又不会太僵硬。5. 常见问题与排查技巧实录5.1 角色移动时动画打滑怎么办动画打滑是第三人称移动最常见的问题表现是角色移动速度和动画播放速度不匹配看起来像在冰面上走。根本原因是代码位移和根骨骼运动没有对齐。排查步骤是这样的先把根骨骼运动关掉只保留代码位移观察角色移动速度是否和动画节奏匹配。如果代码位移的速度和动画里的步幅对不上调整最大速度值。然后再打开根骨骼运动对比开启前后的位移差异。如果开启后角色移动距离明显变长说明根骨骼运动和代码位移叠加了需要在开启根骨骼运动的时候把代码位移的水平分量置零。还有一个可能是动画本身的播放速度不对。Godot的AnimationPlayer可以调整播放速度如果动画片段本身是慢速走路的但代码速度给的是跑步速度那肯定打滑。这种情况要么换动画要么调整动画的播放速度倍率。5.2 状态切换时动画跳变怎么处理状态切换跳变通常是因为过渡时间太短或者没有过渡。在AnimationTree的Transition里每个连接都有一个xfade_time参数默认是0改成0.2到0.3秒就能让动画平滑过渡。但有些切换不能只靠xfade。比如从跑步到急停如果只是简单混合两个动画的姿势差异太大混合出来会扭曲。我的做法是在急停动画的前几帧加一个过渡姿势让角色先有一个减速的预备动作然后再播急停。这个过渡姿势可以在Blender里做也可以在Godot里用AnimationTree的Blend节点手动混合。注意过渡时间不是越长越好。超过0.5秒的过渡会让角色看起来像在慢动作尤其是快速切换的时候。一般0.2到0.3秒是比较合适的范围。5.3 根骨骼运动导致角色位置偏移根骨骼运动有一个常见问题动画播完之后角色位置对不上。比如翻越动画动画里角色从A点移动到B点但播完之后角色实际位置在C点。这是因为根骨骼运动的位移是累积的如果动画循环播放或者过渡的时候没有重置位移就会越积越多。解决办法是在状态切换的时候重置根骨骼运动的累积位移。Godot的AnimationTree有一个set_root_motion_track方法可以在切换状态的时候把根骨骼运动的参考位置重置到当前角色位置。另外对于非循环的动画比如翻越、攀爬播完之后要手动把角色位置对齐到动画结束时的位置。5.4 性能优化与帧率稳定第三人称移动系统涉及动画混合、IK计算、物理检测在低端设备上可能会掉帧。我做了几个优化。动画更新频率从每帧改成每两帧更新一次对于大多数游戏来说视觉上看不出区别但能省不少CPU。IK计算只在需要的时候开启比如脚部IK只在角色着地且移动速度低于阈值的时候计算跑步和跳跃的时候关掉。物理检测的射线长度和频率也做了限制台阶检测的射线从每帧检测改成每三帧检测一次。还有一个容易忽略的是动画片段的压缩。Godot导入动画的时候可以选择压缩模式默认是无损的但动画数据量很大。改成有损压缩后文件大小能减少一半以上播放时的内存占用也会降低。压缩带来的精度损失在大多数情况下看不出来除非是特别精细的面部动画。问题现象可能原因排查方法解决方案角色移动打滑代码位移与根骨骼运动不匹配关闭根骨骼运动对比位移调整速度值或关闭叠加动画切换跳变过渡时间太短检查Transition的xfade_time设为0.2-0.3秒角色位置偏移根骨骼运动累积打印角色位置对比动画切换状态时重置根骨骼帧率下降动画更新过于频繁用性能监视器看动画耗时降低更新频率或压缩动画相机穿墙SpringArm长度不够观察相机与墙壁距离调整SpringArm长度和碰撞遮罩5.5 不同Godot版本的兼容性这个项目是在Godot 4.2上开发的4.0和4.1有一些API差异。主要区别在AnimationTree的参数设置上4.0用的是set_param方法4.1之后改成了set方法。另外CharacterBody3D的move_and_slide在4.0里返回的是bool4.1之后返回的是Vector3。如果你用的是4.0需要把相关调用改一下。从4.2升级到4.3的话基本没有破坏性变更但4.3的动画系统有一些性能改进建议升级。升级之前记得备份项目虽然Godot的版本迁移一般很顺但动画资源有时候需要重新导入。6. 后续可以扩展的方向这套系统目前实现了ALS的核心移动功能但ALS还有一些高级特性没有完全覆盖。比如运动匹配ALS用了一套基于距离的成本计算来在大量动画片段里找最合适的Godot里可以用AnimationNodeBlendSpace2D配合脚本实现类似的效果但需要更多的动画数据。攀爬系统也是可以扩展的方向。目前只做了翻越完整的攀爬需要检测墙面、计算攀爬路径、处理攀爬过程中的动画切换。Godot的NavigationServer3D可以用来做路径规划但攀爬的动画状态机需要单独设计。网络同步是另一个值得考虑的点。如果要做多人游戏角色的移动状态需要同步到其他客户端。Godot的MultiplayerAPI提供了基础的同步功能但动画状态的同步需要自己处理比如把速度、方向、状态这些参数打包成RPC发送。我在实际使用这套系统做项目的时候最大的体会是参数调校比代码实现更花时间。代码写完之后光是调移动速度、加速度、转向速度、相机灵敏度这些参数就花了两天。每个人的手感偏好不一样我的建议是先把所有参数暴露到Inspector里边跑边调找到自己觉得舒服的数值再固定下来。另外动画资源的质量直接决定了最终效果如果动画本身做得不好再好的状态机也救不回来。
返回列表