Quaternius walking: legs and feet Inverse Kinematics Script

Requirements:

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

4 Likes