@tool
class_name HeadIK extends RefCounted
## Overgrowth-style head IK: spring-damper head tracking with acceleration
## inertia. Builds on chest rotation to add independent head look.
##
## Based on DoHeadIK() from aschar.as line 14983.
## Uses pre-IK bone snapshot and skeleton root axes as reference frame.
const HEAD_BONE := "DEF-spine.006" # Head in Rigify
const NECK_BONE := "DEF-spine.005" # Neck in Rigify
# Spring-damper (Overgrowth defaults)
var damping: float = 0.9
var stiffness: float = 0.8
var accel_inertia: float = 0.0
var accel_damping: float = 0.0
# State
var _head_angle: Vector2 = Vector2.ZERO
var _head_angle_vel: Vector2 = Vector2.ZERO
var _target_head_angle: Vector2 = Vector2.ZERO
var _old_head_angle: float = 0.0
var _head_accel: Vector2 = Vector2.ZERO
# External input
var look_direction: Vector3 = Vector3.ZERO
var idle_amount: float = 0.0
## Apply head IK. Call after ChestIK.
## pre_ik_globals: { bone_name: Transform3D } snapshot from before IK.
func apply(skeleton: Skeleton3D, delta: float,
pre_ik_globals: Dictionary) -> void:
if skeleton == null:
return
var head_idx: int = skeleton.find_bone(HEAD_BONE)
if head_idx < 0:
return
var gx: Transform3D = skeleton.global_transform
# Reference frame: skeleton root axes (stable, rig-independent)
var ref_forward: Vector3 = -gx.basis.z.normalized() # Character visual forward (skeleton has 180° Y flip from glTF)
var ref_up: Vector3 = gx.basis.y.normalized()
var ref_right: Vector3 = gx.basis.x.normalized()
# No look target → character forward (zero angle = no rotation)
var look: Vector3 = look_direction if look_direction.length_squared() > 0.001 else ref_forward
# Yaw from look direction relative to character forward
var look_flat := Vector2.ZERO
look_flat.x = look.dot(ref_right)
look_flat.y = look.dot(ref_forward)
var angle: float = atan2(-look_flat.x, look_flat.y)
# Anti-flip
var head_range: float = 1.2
var angle_threshold: float = 2.2
if angle > angle_threshold and _old_head_angle <= -head_range:
angle = -head_range
elif angle < -angle_threshold and _old_head_angle >= head_range:
angle = head_range
angle = clampf(angle, -head_range, head_range)
_old_head_angle = angle
# Pitch
var tilt_offset: float = -0.2
var pitch: float = asin(clampf(look.normalized().dot(ref_up) + tilt_offset, -1.0, 1.0))
var look_amount: float = 1.0 if look_direction.length_squared() > 0.001 else 0.0
_target_head_angle.x = angle * look_amount
_target_head_angle.y = pitch * look_amount * idle_amount
# Spring-damper
var idle_inertia: float = lerpf(0.0, 0.99, idle_amount)
var idle_accel_damp: float = lerpf(0.0, 0.95, idle_amount)
var frames: float = delta * 60.0
var d: float = lerpf(damping, 0.9, idle_amount)
var s: float = lerpf(stiffness, 1.0, idle_amount)
_head_angle_vel *= pow(d, frames)
var accel: Vector2 = (_target_head_angle - _head_angle) * s * frames
_head_accel = _head_accel * pow(idle_inertia, frames) + accel * (1.0 - pow(idle_inertia, frames))
_head_accel *= pow(1.0 - idle_accel_damp, frames)
_head_angle_vel += _head_accel
_head_angle += _head_angle_vel * delta
# Build rotation using character axes (not bone axes)
var yaw_rot := Quaternion(ref_up, _head_angle.x)
var pitch_rot := Quaternion(ref_right, _head_angle.y)
var head_rotate: Quaternion = yaw_rot * pitch_rot
# Split 50/50 neck + head
var half: Quaternion = Quaternion.IDENTITY.slerp(head_rotate, 0.5)
var neck_idx: int = skeleton.find_bone(NECK_BONE)
if neck_idx >= 0:
_apply_local_rotation(skeleton, gx, neck_idx, half, pre_ik_globals)
_apply_local_rotation(skeleton, gx, head_idx, half, pre_ik_globals)
func _apply_local_rotation(skeleton: Skeleton3D, gx: Transform3D,
bone_idx: int, world_rot: Quaternion,
pre_ik_globals: Dictionary) -> void:
var parent_idx: int = skeleton.get_bone_parent(bone_idx)
if parent_idx < 0:
return
var parent_name: String = skeleton.get_bone_name(parent_idx)
var parent_global: Transform3D = pre_ik_globals.get(parent_name, gx * skeleton.get_bone_global_pose(parent_idx))
var parent_basis: Basis = parent_global.basis
var local_rot: Quaternion = (parent_basis.inverse() * Basis(world_rot) * parent_basis).get_rotation_quaternion()
var current: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
skeleton.set_bone_pose_rotation(bone_idx, local_rot * current)