OverviewFilesCommitsIssuesDocsReleasesPeers

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

123 lines · gd ref:
@tool
class_name ChestIK extends RefCounted
## Overgrowth-style chest IK: spring-damper look-at rotation.
## Rotates chest toward look direction with spring physics.
##
## Based on DoChestIK() from aschar.as line 14857.
## Uses pre-IK bone snapshot to prevent feedback loops.
## Reference frame: skeleton root transform (not bone axes).

const CHEST_BONE := "DEF-spine.003"  # Upper chest in Rigify
const SPINE_BONE := "DEF-spine.002"  # Mid spine (abdomen)
const LOWER_SPINE_BONE := "DEF-spine.001"  # Lower spine, just above the pelvis

# Spring-damper parameters (Overgrowth defaults)
var damping: float = 0.93
var stiffness: float = 0.5
var idle_damping: float = 0.99
var idle_stiffness: float = 0.03

# State
var _chest_angle: Vector2 = Vector2.ZERO       # Current rotation (yaw, pitch)
var _chest_angle_vel: Vector2 = Vector2.ZERO    # Angular velocity
var _target_chest_angle: Vector2 = Vector2.ZERO
var _old_chest_angle: float = 0.0

# Look target (set by controller)
var look_direction: Vector3 = Vector3.ZERO
var idle_amount: float = 0.0  ## 0 = combat, 1 = idle


## Apply chest IK rotation.
## pre_ik_globals: { bone_name: Transform3D } snapshot from before IK.
func apply(skeleton: Skeleton3D, delta: float,
		pre_ik_globals: Dictionary) -> Quaternion:
	if skeleton == null:
		return Quaternion.IDENTITY

	var chest_idx: int = skeleton.find_bone(CHEST_BONE)
	if chest_idx < 0:
		return Quaternion.IDENTITY

	var gx: Transform3D = skeleton.global_transform

	# Reference frame: use the skeleton's root transform axes.
	# This is stable — it's the character's body orientation,
	# not the bone's local axes which vary per rig.
	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 → default to character forward (zero angle = no rotation)
	var look: Vector3 = look_direction if look_direction.length_squared() > 0.001 else ref_forward

	# Compute yaw: angle between look and character forward on the XZ plane
	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 when looking straight back (Overgrowth line 14915)
	var head_range: float = 1.0
	if angle > 2.2 and _old_chest_angle <= -head_range:
		angle = -head_range
	elif angle < -2.2 and _old_chest_angle >= head_range:
		angle = head_range
	angle = clampf(angle, -head_range, head_range)
	_old_chest_angle = angle

	# Pitch from look direction
	var pitch: float = asin(clampf(look.normalized().dot(ref_up), -1.0, 1.0))

	var look_amount: float = 1.0 if look_direction.length_squared() > 0.001 else 0.0
	_target_chest_angle.x = angle * look_amount
	_target_chest_angle.y = pitch * look_amount * idle_amount

	# Subtle breathing shake (Overgrowth line 14928)
	var shake: float = 0.005
	_target_chest_angle.x += randf_range(-shake, shake)
	_target_chest_angle.y += randf_range(-shake, shake)

	# Spring-damper update (Overgrowth line 14932-14934)
	var d: float = lerpf(damping, idle_damping, idle_amount)
	var s: float = lerpf(stiffness, idle_stiffness, idle_amount)
	var frames: float = delta * 60.0

	_chest_angle_vel *= pow(d, frames)
	_chest_angle_vel += (_target_chest_angle - _chest_angle) * s * frames
	_chest_angle += _chest_angle_vel * delta

	# Build rotation in world space using character axes
	var yaw_rot := Quaternion(ref_up, _chest_angle.x)
	var pitch_rot := Quaternion(ref_right, _chest_angle.y)
	var chest_rotate: Quaternion = yaw_rot * pitch_rot

	# SPREAD DOWN THE SPINE, a third per bone. Overgrowth's rig has hip -> abdomen ->
	# chest and DrawBody blends the abdomen 50% between hip and chest, so a turn is
	# shared by every joint above the pelvis. Ours has THREE bones there
	# (DEF-spine.001/.002/.003); the port put half on .002 and half on .003 and left
	# .001 straight, which reads as the torso turning at one kink in the middle of
	# the back (gate_spine_look: 0% / 50% / 50%).
	var third_rot: Quaternion = Quaternion.IDENTITY.slerp(chest_rotate, 1.0 / 3.0)
	for bone_name: String in [LOWER_SPINE_BONE, SPINE_BONE]:
		var idx: int = skeleton.find_bone(bone_name)
		if idx >= 0:
			_apply_local_rotation(skeleton, gx, idx, third_rot, pre_ik_globals)
	_apply_local_rotation(skeleton, gx, chest_idx, third_rot, pre_ik_globals)

	return chest_rotate


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)