Requirements:
- https://quaternius.itch.io/universal-animation-library
- You might need to retarget the skeleton (to make it GeneralSkeleton)
GDScript
extends Node3D
@export_category("Node Connections")
@export var skeleton_path: NodePath = "AnimationLibrary_Godot_Standard/Rig/GeneralSkeleton"
@onready var skeleton: Skeleton3D = get_node_or_null(skeleton_path)
@export_category("Bone Names")
@export var left_upper_leg: String = "LeftUpperLeg"
@export var left_foot: String = "LeftFoot"
@export var right_upper_leg: String = "RightUpperLeg"
@export var right_foot: String = "RightFoot"
@export_category("IK Settings")
@export var foot_offset: float = 0.16
@export var raycast_height: float = 1.0
@export var raycast_length: float = 3.0
@export var blend_speed: float = 15.0
@export var knee_magnet_z: float = 3.0
@export var ground_align_strength: float = 0.7
@export_category("Debug")
@export var show_debug_visuals: bool = true
var left_ray: RayCast3D
var right_ray: RayCast3D
var left_target: Marker3D
var right_target: Marker3D
var left_ik: SkeletonIK3D
var right_ik: SkeletonIK3D
var left_foot_idx: int = -1
var right_foot_idx: int = -1
var left_anim_global_basis: Basis = Basis.IDENTITY
var right_anim_global_basis: Basis = Basis.IDENTITY
var left_ground_normal: Vector3 = Vector3.UP
var right_ground_normal: Vector3 = Vector3.UP
var left_ik_weight: float = 0.0
var right_ik_weight: float = 0.0
var _ik_ready: bool = false
func _ready():
if not skeleton:
push_error("FootIK: skeleton not found at path: " + str(skeleton_path))
return
left_foot_idx = skeleton.find_bone(left_foot)
right_foot_idx = skeleton.find_bone(right_foot)
if left_foot_idx == -1:
push_error("FootIK: bone not found: " + left_foot)
if right_foot_idx == -1:
push_error("FootIK: bone not found: " + right_foot)
left_target = _create_target("LeftFootTarget")
left_ray = _create_raycast("LeftFootRay")
right_target = _create_target("RightFootTarget")
right_ray = _create_raycast("RightFootRay")
# override_tip_basis = true so IK applies our target orientation to the foot
left_ik = _create_skeleton_ik("LeftLegIK", left_upper_leg, left_foot, left_target)
right_ik = _create_skeleton_ik("RightLegIK", right_upper_leg, right_foot, right_target)
call_deferred("_start_ik")
func _start_ik():
if left_ik and left_ik.is_inside_tree():
left_ik.start()
else:
push_error("FootIK: left_ik not in tree")
if right_ik and right_ik.is_inside_tree():
right_ik.start()
else:
push_error("FootIK: right_ik not in tree")
_ik_ready = true
func _process(delta):
if not skeleton or not _ik_ready:
return
if is_instance_valid(left_ik):
left_ik.magnet = Vector3(0, 1.0, knee_magnet_z)
if is_instance_valid(right_ik):
right_ik.magnet = Vector3(0, 1.0, knee_magnet_z)
_update_foot_ik(left_ray, left_target, left_ik, left_foot_idx, delta, "left")
_update_foot_ik(right_ray, right_target, right_ik, right_foot_idx, delta, "right")
func _update_foot_ik(
ray: RayCast3D, target: Marker3D, ik: SkeletonIK3D,
foot_bone_idx: int, delta: float, side: String) -> void:
if foot_bone_idx == -1: return
if not is_instance_valid(ray) or not is_instance_valid(target): return
if not is_instance_valid(ik): return
# Capture animation pose before IK override
var anim_pose: Transform3D = skeleton.get_bone_global_pose_no_override(foot_bone_idx)
var anim_global: Transform3D = skeleton.global_transform * anim_pose
if side == "left":
left_anim_global_basis = anim_global.basis
else:
right_anim_global_basis = anim_global.basis
# Raycast from above animated foot position
ray.global_position = anim_global.origin + Vector3(0, raycast_height, 0)
ray.force_raycast_update()
var target_weight: float = 0.0
var target_pos: Vector3 = anim_global.origin
var ground_normal: Vector3 = Vector3.UP
if ray.is_colliding():
var hit_point: Vector3 = ray.get_collision_point()
var vertical_dist: float = anim_global.origin.y - hit_point.y
var raw_inf: float = clamp(1.0 - (vertical_dist / 0.5), 0.0, 1.0)
target_weight = raw_inf * raw_inf
target_pos = hit_point + Vector3(0, foot_offset, 0)
ground_normal = ray.get_collision_normal()
if side == "left":
left_ground_normal = left_ground_normal.lerp(ground_normal, blend_speed * delta)
left_ik_weight = lerp(left_ik_weight, target_weight, blend_speed * delta)
else:
right_ground_normal = right_ground_normal.lerp(ground_normal, blend_speed * delta)
right_ik_weight = lerp(right_ik_weight, target_weight, blend_speed * delta)
var anim_basis: Basis = (left_anim_global_basis if side == "left" else right_anim_global_basis)
var ik_weight: float = (left_ik_weight if side == "left" else right_ik_weight)
var g_normal: Vector3 = (left_ground_normal if side == "left" else right_ground_normal)
# Tilt the foot's +Z axis (confirmed "up" for this rig) onto the ground normal
var tilted_basis: Basis = _tilt_z_to_normal(anim_basis, g_normal, ik_weight * ground_align_strength)
target.global_position = target.global_position.lerp(target_pos, blend_speed * delta)
target.global_transform.basis = tilted_basis
ik.interpolation = lerp(ik.interpolation, target_weight, blend_speed * delta)
# Rotates `basis` so its +Z axis aligns with ground_normal, blended by strength.
# +Z is the confirmed "up" axis for LeftFoot/RightFoot in this rig.
func _tilt_z_to_normal(basis: Basis, ground_normal: Vector3, strength: float) -> Basis:
if strength < 0.001:
return basis
var b: Basis = basis.orthonormalized()
var current_up: Vector3 = b.z # +Z is "up" in this rig
var target_up: Vector3 = ground_normal.normalized()
var dot: float = clamp(current_up.dot(target_up), -1.0, 1.0)
if dot > 0.9999:
return basis
var rot_axis: Vector3 = current_up.cross(target_up).normalized()
var angle: float = acos(dot) * strength
var correction: Quaternion = Quaternion(rot_axis, angle)
var tilted: Basis = Basis(correction) * b
return tilted.scaled(basis.get_scale())
# ── Helpers ──────────────────────────────────────────────────────
func _create_target(t_name: String) -> Marker3D:
var m = Marker3D.new()
m.name = t_name
if show_debug_visuals:
var mi = MeshInstance3D.new()
var s = SphereMesh.new()
s.radius = 0.05
s.height = 0.1
var mat = StandardMaterial3D.new()
mat.albedo_color = Color.RED
mi.mesh = s
mi.material_override = mat
m.add_child(mi)
add_child(m)
return m
func _create_raycast(r_name: String) -> RayCast3D:
var r = RayCast3D.new()
r.name = r_name
r.target_position = Vector3(0, -raycast_length, 0)
r.collision_mask = 1
add_child(r)
return r
func _create_skeleton_ik(ik_name: String, r_bone: String, t_bone: String, target: Marker3D) -> SkeletonIK3D:
var ik = SkeletonIK3D.new()
ik.name = ik_name
ik.root_bone = r_bone
ik.tip_bone = t_bone
ik.override_tip_basis = true # Apply target orientation to foot bone
ik.use_magnet = true
skeleton.add_child(ik)
ik.target_node = target.get_path()
return ik