OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / head_ik.gd

117 lines · gd ref:
@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)