8. 高级动画技术
高级动画技术
你已经在基础篇学会了简单的 AnimationPlayer 播放动画。但真正让角色"活"起来,需要的远不止"播放一段剪辑"这么简单。想象一下:你的角色在上坡时脚要踩实地面、跑动中要能平滑过渡到攻击动画、头部还要追踪附近飞过的鸟——这些就是高级动画技术要解决的问题。
本章你将学到
- AnimationTree 与 BlendSpace2D:根据角色速度和方向自动混合动画
- 动画状态机进阶:条件过渡与回调函数
- 骨骼 IK(逆运动学):让脚踩到不平的地面上
- LookAt 修改器:角色头部/眼睛自动跟踪目标
- 动画同步:多人游戏中同步动画状态
- 程序化动画:布娃娃系统与物理驱动动画
AnimationTree 与 BlendSpace2D
什么是 AnimationTree
AnimationTree 就像一个"动画指挥家"。AnimationPlayer 只能播放一段动画,而 AnimationTree 可以在多段动画之间平滑混合。比如角色从走路过渡到跑步,不是突然切换,而是速度逐渐加快、动作逐渐变大。
BlendSpace2D:二维混合空间
BlendSpace2D 是 AnimationTree 中最强大的节点之一。你可以把它想象成一张"地图"——X 轴代表角色的移动速度,Y 轴代表方向(比如前进、左移、右移)。Godot 会根据角色的实际速度和方向,在这张地图上找到对应的"位置",然后自动混合周围的动画。
代码示例:设置 AnimationTree
using Godot;
public partial class CharacterAnimation : Node
{
private AnimationTree _animTree;
private AnimationNodeStateMachinePlayback _stateMachine;
public override void _Ready()
{
_animTree = GetNode<AnimationTree>("AnimationTree");
// 获取状态机的播放控制器
_stateMachine = (AnimationNodeStateMachinePlayback)
_animTree.Get("parameters/playback");
// 启用动画树
_animTree.Active = true;
}
public override void _PhysicsProcess(double delta)
{
// 获取角色速度
var character = GetParent() as CharacterBody3D;
if (character == null) return;
Vector3 velocity = character.Velocity;
float speed = new Vector2(velocity.X, velocity.Z).Length();
// 设置 BlendSpace2D 的参数(速度和方向)
_animTree.Set("parameters/Move/blend_position", new Vector2(
Mathf.Sign(velocity.X) * speed,
speed
));
// 根据速度切换状态
if (speed < 0.1f)
{
_stateMachine.Travel("Idle");
}
else if (speed < 4.0f)
{
_stateMachine.Travel("Walk");
}
else
{
_stateMachine.Travel("Run");
}
}
}extends Node
var anim_tree: AnimationTree
var state_machine: AnimationNodeStateMachinePlayback
func _ready():
anim_tree = $AnimationTree
# 获取状态机的播放控制器
state_machine = anim_tree["parameters/playback"]
# 启用动画树
anim_tree.active = true
func _physics_process(delta):
# 获取角色速度
var character = get_parent() as CharacterBody3D
if character == null:
return
var vel = character.velocity
var speed = Vector2(vel.x, vel.z).length()
# 设置 BlendSpace2D 的参数(速度和方向)
anim_tree["parameters/Move/blend_position"] = Vector2(
sign(vel.x) * speed,
speed
)
# 根据速度切换状态
if speed < 0.1:
state_machine.travel("Idle")
elif speed < 4.0:
state_machine.travel("Walk")
else:
state_machine.travel("Run")动画状态机进阶
条件过渡
状态机中的"过渡"就像红绿灯——只有满足条件才能通行。比如只有当角色处于"落地"状态时才能过渡到"走路"状态。在 AnimationTree 编辑器中,你可以为每个过渡设置条件表达式。
回调函数
动画播放到特定帧时可以触发一个函数调用,这在制作连击系统时非常有用。比如在攻击动画的"挥刀到最低点"那一帧触发伤害判定。
using Godot;
public partial class CombatCharacter : CharacterBody3D
{
private AnimationPlayer _animPlayer;
private bool _canDealDamage;
public override void _Ready()
{
_animPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
// 连接动画信号:在特定帧触发回调
_animPlayer.AnimationFinished += OnAnimationFinished;
}
// 这个方法会在动画的关键帧被调用(通过 AnimationPlayer 的 Track 设置)
public void OnAttackHitFrame()
{
// 攻击动画到达伤害判定帧时调用
_canDealDamage = true;
GD.Print("伤害判定帧触发!");
// 检测范围内的敌人并造成伤害
var area = GetNode<Area3D>("AttackArea");
foreach (var body in area.GetOverlappingBodies())
{
if (body.IsInGroup("enemies"))
{
GD.Print($"命中敌人: {body.Name}");
}
}
}
private void OnAnimationFinished(StringName animName)
{
if (animName == "attack")
{
_canDealDamage = false;
// 攻击结束,回到待机
_animPlayer.Play("idle");
}
}
}extends CharacterBody3D
var anim_player: AnimationPlayer
var can_deal_damage: bool = false
func _ready():
anim_player = $AnimationPlayer
# 连接动画信号
anim_player.animation_finished.connect(_on_animation_finished)
# 这个方法会在动画的关键帧被调用(通过 AnimationPlayer 的 Track 设置)
func on_attack_hit_frame():
# 攻击动画到达伤害判定帧时调用
can_deal_damage = true
print("伤害判定帧触发!")
# 检测范围内的敌人并造成伤害
var area = $AttackArea
for body in area.get_overlapping_bodies():
if body.is_in_group("enemies"):
print("命中敌人: ", body.name)
func _on_animation_finished(anim_name: StringName):
if anim_name == "attack":
can_deal_damage = false
# 攻击结束,回到待机
anim_player.play("idle")骨骼 IK(逆运动学)
IK 是什么?打个比方:你伸手去抓桌上的杯子时,你不是先想"手肘弯多少度、肩膀转多少度",而是直接想"手要到哪里"。IK 就是这个原理——你指定末端(比如脚)的位置,引擎自动计算出中间关节(膝盖、髋关节)该怎么弯曲。
最常见的用途是脚部 IK:角色走在凹凸不平的地面上时,脚要贴合地面,不能悬空也不能穿过地面。
using Godot;
public partial class FootIK : Node
{
[Export] public Skeleton3D Skeleton { get; set; }
[Export] public float RayLength { get; set; } = 2.0f;
private int _leftFootBoneId;
private int _rightFootBoneId;
private PhysicsDirectSpaceState3D _spaceState;
public override void _Ready()
{
_leftFootBoneId = Skeleton.FindBone("LeftFoot");
_rightFootBoneId = Skeleton.FindBone("RightFoot");
}
public override void _PhysicsProcess(double delta)
{
_spaceState = Skeleton.GetWorld3D().DirectSpaceState;
ApplyFootIK(_leftFootBoneId);
ApplyFootIK(_rightFootBoneId);
}
private void ApplyFootIK(int boneId)
{
// 获取骨骼在全局空间中的位置
Vector3 boneGlobalPos = Skeleton.ToGlobal(
Skeleton.GetBoneGlobalPose(boneId).Origin
);
// 向下发射射线检测地面
var query = PhysicsRayQueryParameters3D.Create(
boneGlobalPos + Vector3.Up * 0.5f,
boneGlobalPos - Vector3.Up * RayLength
);
var result = _spaceState.IntersectRay(query);
if (result.Count > 0)
{
Vector3 hitPos = (Vector3)result["position"];
// 将骨骼位置调整到地面碰撞点
Transform3D bonePose = Skeleton.GetBoneGlobalPose(boneId);
bonePose.Origin = Skeleton.ToLocal(hitPos);
Skeleton.SetBoneGlobalPose(boneId, bonePose);
}
}
}extends Node
@export var skeleton: Skeleton3D
@export var ray_length: float = 2.0
var left_foot_bone_id: int
var right_foot_bone_id: int
var space_state: PhysicsDirectSpaceState3D
func _ready():
left_foot_bone_id = skeleton.find_bone("LeftFoot")
right_foot_bone_id = skeleton.find_bone("RightFoot")
func _physics_process(delta):
space_state = skeleton.get_world_3d().direct_space_state
_apply_foot_ik(left_foot_bone_id)
_apply_foot_ik(right_foot_bone_id)
func _apply_foot_ik(bone_id: int):
# 获取骨骼在全局空间中的位置
var bone_global_pos = skeleton.to_global(
skeleton.get_bone_global_pose(bone_id).origin
)
# 向下发射射线检测地面
var query = PhysicsRayQueryParameters3D.create(
bone_global_pos + Vector3.UP * 0.5,
bone_global_pos - Vector3.UP * ray_length
)
var result = space_state.intersect_ray(query)
if result:
var hit_pos = result["position"]
# 将骨骼位置调整到地面碰撞点
var bone_pose = skeleton.get_bone_global_pose(bone_id)
bone_pose.origin = skeleton.to_local(hit_pos)
skeleton.set_bone_global_pose(bone_id, bone_pose)IK 的性能注意
骨骼 IK 每帧都需要做射线检测和矩阵运算。对于大量 NPC(比如 50 个以上),建议只在主角和重要 NPC 上使用 IK,普通 NPC 用简单的动画即可。
LookAt 修改器
LookAt 修改器让角色的某个骨骼(通常是头部或眼睛)自动朝向一个目标。这在制作 NPC 注视玩家、炮塔追踪敌人等效果时非常有用。
using Godot;
public partial class HeadTracker : Node
{
[Export] public Skeleton3D Skeleton { get; set; }
[Export] public Node3D Target { get; set; }
[Export] public float RotationSpeed { get; set; } = 5.0f;
[Export] public float MaxAngle { get; set; } = 60.0f; // 最大旋转角度
private int _headBoneId;
private Quaternion _restRotation;
public override void _Ready()
{
_headBoneId = Skeleton.FindBone("Head");
_restRotation = Skeleton.GetBonePose(_headBoneId).Basis.GetRotationQuaternion();
}
public override void _Process(double delta)
{
if (Target == null) return;
// 获取头部骨骼在全局空间的位置
Vector3 headGlobalPos = Skeleton.ToGlobal(
Skeleton.GetBoneGlobalPose(_headBoneId).Origin
);
// 计算朝向目标的方向
Vector3 direction = (Target.GlobalPosition - headGlobalPos).Normalized();
Basis lookAtBasis = Basis.LookingAt(direction, Vector3.Up);
Quaternion targetRotation = lookAtBasis.GetRotationQuaternion();
// 限制旋转角度
Quaternion currentRest = Skeleton.GetBoneGlobalPose(_headBoneId)
.Basis.GetRotationQuaternion();
Quaternion diff = targetRotation * currentRest.Inverse();
float angle = diff.Angle();
if (angle <= Mathf.DegToRad(MaxAngle))
{
// 平滑插值
Quaternion newRot = currentRest.Slerp(targetRotation,
(float)delta * RotationSpeed);
Transform3D pose = Skeleton.GetBoneGlobalPose(_headBoneId);
pose.Basis = new Basis(newRot);
Skeleton.SetBoneGlobalPose(_headBoneId, pose);
}
}
}extends Node
@export var skeleton: Skeleton3D
@export var target: Node3D
@export var rotation_speed: float = 5.0
@export var max_angle: float = 60.0 # 最大旋转角度
var head_bone_id: int
var rest_rotation: Quaternion
func _ready():
head_bone_id = skeleton.find_bone("Head")
rest_rotation = skeleton.get_bone_pose(head_bone_id).basis.get_rotation_quaternion()
func _process(delta):
if target == null:
return
# 获取头部骨骼在全局空间的位置
var head_global_pos = skeleton.to_global(
skeleton.get_bone_global_pose(head_bone_id).origin
)
# 计算朝向目标的方向
var direction = (target.global_position - head_global_pos).normalized()
var look_at_basis = Basis.looking_at(direction, Vector3.UP)
var target_rotation = look_at_basis.get_rotation_quaternion()
# 限制旋转角度
var current_rest = skeleton.get_bone_global_pose(head_bone_id) \
.basis.get_rotation_quaternion()
var diff = target_rotation * current_rest.inverse()
var angle = diff.angle()
if angle <= deg_to_rad(max_angle):
# 平滑插值
var new_rot = current_rest.slerp(target_rotation, delta * rotation_speed)
var pose = skeleton.get_bone_global_pose(head_bone_id)
pose.basis = Basis(new_rot)
skeleton.set_bone_global_pose(head_bone_id, pose)动画同步(多人游戏)
在多人游戏中,所有玩家看到的角色动画必须一致。基本思路是:每个客户端只在本地计算动画参数(速度、状态),然后通过 RPC 把关键参数发给其他客户端,其他客户端用这些参数驱动本地的 AnimationTree。
using Godot;
public partial class MultiplayerAnimation : Node
{
private AnimationTree _animTree;
public override void _Ready()
{
_animTree = GetNode<AnimationTree>("AnimationTree");
}
// 在本地客户端调用——把动画参数同步到远端
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = false)]
public void SendAnimationState(float speed, int state)
{
_animTree.Set("parameters/Move/blend_position", speed);
var playback = (AnimationNodeStateMachinePlayback)
_animTree.Get("parameters/playback");
switch (state)
{
case 0: playback.Travel("Idle"); break;
case 1: playback.Travel("Walk"); break;
case 2: playback.Travel("Run"); break;
case 3: playback.Travel("Attack"); break;
}
}
public override void _PhysicsProcess(double delta)
{
// 只有本地玩家才发送动画参数
if (!IsMultiplayerAuthority()) return;
var character = GetParent() as CharacterBody3D;
if (character == null) return;
float speed = new Vector2(
character.Velocity.X, character.Velocity.Z
).Length();
int state = speed < 0.1f ? 0 : speed < 4.0f ? 1 : 2;
// 发送给其他所有客户端
Rpc("SendAnimationState", speed, state);
}
}extends Node
var anim_tree: AnimationTree
func _ready():
anim_tree = $AnimationTree
# 在本地客户端调用——把动画参数同步到远端
@rpc("any_peer", call_local = false)
func send_animation_state(speed: float, state: int):
anim_tree["parameters/Move/blend_position"] = speed
var playback = anim_tree["parameters/playback"]
match state:
0: playback.travel("Idle")
1: playback.travel("Walk")
2: playback.travel("Run")
3: playback.travel("Attack")
func _physics_process(delta):
# 只有本地玩家才发送动画参数
if not is_multiplayer_authority():
return
var character = get_parent() as CharacterBody3D
if character == null:
return
var speed = Vector2(character.velocity.x, character.velocity.z).length()
var state = 0
if speed < 0.1:
state = 0
elif speed < 4.0:
state = 1
else:
state = 2
# 发送给其他所有客户端
rpc("send_animation_state", speed, state)程序化动画
不是所有动画都需要事先在编辑器里做好。有些动画是代码"实时计算"出来的——这就是程序化动画。典型例子:布娃娃系统(角色死亡后身体软绵绵地倒下)、布料飘动、角色被爆炸冲击波弹飞。
using Godot;
public partial class RagdollController : Node
{
[Export] public Skeleton3D Skeleton { get; set; }
[Export] public AnimationPlayer AnimPlayer { get; set; }
private bool _isRagdoll = false;
public override void _Ready()
{
// 初始时禁用所有物理骨骼
SetPhysicalBonesActive(false);
}
public void EnableRagdoll()
{
if (_isRagdoll) return;
_isRagdoll = true;
// 停止动画播放
AnimPlayer.Stop();
// 启用所有物理骨骼模拟
SetPhysicalBonesActive(true);
// 可选:给骨骼一个初始冲量(模拟被击飞)
foreach (var child in Skeleton.GetChildren())
{
if (child is PhysicalBone3D bone)
{
bone.ApplyCentralImpulse(
Vector3.Back * 5.0f + Vector3.Up * 3.0f
);
}
}
GD.Print("布娃娃模式已启用");
}
public void DisableRagdoll()
{
_isRagdoll = false;
SetPhysicalBonesActive(false);
AnimPlayer.Play("idle");
GD.Print("布娃娃模式已禁用");
}
private void SetPhysicalBonesActive(bool active)
{
foreach (var child in Skeleton.GetChildren())
{
if (child is PhysicalBone3D bone)
{
if (active)
bone.StartSimulation();
else
bone.StopSimulation();
}
}
}
}extends Node
@export var skeleton: Skeleton3D
@export var anim_player: AnimationPlayer
var is_ragdoll: bool = false
func _ready():
# 初始时禁用所有物理骨骼
_set_physical_bones_active(false)
func enable_ragdoll():
if is_ragdoll:
return
is_ragdoll = true
# 停止动画播放
anim_player.stop()
# 启用所有物理骨骼模拟
_set_physical_bones_active(true)
# 可选:给骨骼一个初始冲量(模拟被击飞)
for child in skeleton.get_children():
if child is PhysicalBone3D:
child.apply_central_impulse(
Vector3.BACK * 5.0 + Vector3.UP * 3.0
)
print("布娃娃模式已启用")
func disable_ragdoll():
is_ragdoll = false
_set_physical_bones_active(false)
anim_player.play("idle")
print("布娃娃模式已禁用")
func _set_physical_bones_active(active: bool):
for child in skeleton.get_children():
if child is PhysicalBone3D:
if active:
child.start_simulation()
else:
child.stop_simulation()物理骨骼的设置
使用布娃娃系统前,需要在 Skeleton3D 下为每个需要物理模拟的骨骼添加 PhysicalBone3D 节点,并设置碰撞形状和关节。Godot 编辑器提供了"创建物理骨骼"的一键工具,可以自动完成大部分工作。
本章小结
| 技术 | 用途 | 性能消耗 |
|---|---|---|
| AnimationTree + BlendSpace2D | 角色移动动画混合 | 低 |
| 动画状态机 + 条件过渡 | 复杂状态切换逻辑 | 低 |
| 回调函数(Call Method Track) | 攻击判定、特效触发 | 低 |
| 骨骼 IK | 脚部贴合不平地面 | 中(每帧射线检测) |
| LookAt 修改器 | 头部/眼睛追踪目标 | 低 |
| 动画同步 RPC | 多人游戏动画一致 | 低(网络带宽) |
| 布娃娃/程序化动画 | 物理驱动的动态动画 | 高(物理模拟) |
选择动画技术时遵循"够用就好"原则:简单动画用 AnimationPlayer,混合需求用 AnimationTree,只有真正需要物理交互时才用程序化动画。
相关章节
- 基础篇 - 3D 动画:动画基础知识
- 多人游戏同步:动画同步的网络层实现
- 性能优化:动画系统的性能开销
