@tool
class_name TorsoCounterRoll extends RefCounted
## Counter-ROLL the spine so the head stays level with the GROUND while the body
## is tilted — the thing that makes a wall run read as a wall run rather than as
## a character glued sideways to a surface.
##
## WHY THIS IS A SEPARATE MODULE. ChestIK and HeadIK are look-at systems: both
## build their rotation as yaw about the skeleton's up and pitch about its right,
## in the SKELETON'S OWN FRAME. Neither has a roll axis, and neither could — a
## look direction does not determine roll. So when the body tilts, the head tilts
## with it and there is nothing in either spring to pull it back.
##
## Roll is about a third axis (the character's forward), so this composes with
## both rather than fighting them: it runs AFTER HeadIK, on the same pre-IK
## snapshot, and only ever adds rotation about an axis the others never touch.
##
## The correction is DISTRIBUTED down the chain rather than hinged at one joint,
## because a single joint taking 45 degrees is a broken neck, not a lean.
const CHAIN := ["DEF-spine.002", "DEF-spine.003", "DEF-spine.005", "DEF-spine.006"]
const WEIGHTS := [0.30, 0.30, 0.20, 0.20] ## sums to 1.0 — abdomen, chest, neck, head
## 0 = no correction (head rides the body), 1 = head fully level with the ground.
## Not 1.0 by default: fully cancelling the tilt reads as a mannequin head bolted
## to a horizon, and the spec asks for "roughly perpendicular to the ground".
var strength: float = 0.75
## Smoothing, so entering and leaving a wall run is a lean rather than a snap.
var smooth_base: float = 0.02
var _roll: float = 0.0
var _last_axis: Vector3 = Vector3.ZERO
## `active` false relaxes the correction back to zero instead of dropping it, so
## leaving the wall unwinds rather than popping.
## `body_up` and `body_fwd` are the MODEL's axes in world space, NOT the
## skeleton's. The skeleton carries an import rotation — measured, its head bone
## sits at (0, 0.869, -0.495) with the body perfectly upright — so a roll derived
## from skeleton.global_transform.basis.y is measuring the rig, not the lean. The
## first version of this file did exactly that and rolled the head 115 degrees
## while the body was vertical.
func apply(skeleton: Skeleton3D, delta: float, active: bool,
pre_ik_globals: Dictionary, body_up: Vector3, body_fwd: Vector3) -> void:
if skeleton == null:
return
var gx: Transform3D = skeleton.global_transform
# SHORTEST ARC, not a roll about the character's forward.
#
# Rolling about the forward is only equivalent to "put the head back up" when
# the forward is perpendicular to the lean -- and once the body leans, the
# model's forward is generally NOT horizontal, so rolling about it cannot
# restore up and introduces yaw and pitch error instead. That is a corkscrew.
#
# The minimal rotation taking the body's up to the world's up is
# axis = body_up x world_up, and it is correct for every facing. `body_fwd`
# is kept in the signature for callers and no longer steers the correction.
var axis: Vector3 = Vector3.ZERO
var target_roll: float = 0.0
if active and body_up.length_squared() > 1e-6:
var bu: Vector3 = body_up.normalized()
axis = bu.cross(Vector3.UP)
if axis.length_squared() > 1e-8:
axis = axis.normalized()
target_roll = bu.angle_to(Vector3.UP) * strength
else:
axis = Vector3.ZERO
var frames: float = delta * 60.0
var k: float = pow(smooth_base, frames)
_roll = lerpf(target_roll, _roll, k)
if absf(_roll) < 0.0005 or axis.length_squared() < 0.5:
# Axis is only defined while active; relaxing back to zero needs the last
# one, so keep it until the roll is spent.
if absf(_roll) < 0.0005:
_last_axis = Vector3.ZERO
if _last_axis.length_squared() < 0.5:
return
axis = _last_axis
_last_axis = axis
for i in range(CHAIN.size()):
var idx: int = skeleton.find_bone(CHAIN[i])
if idx < 0:
continue
var rot := Quaternion(axis, _roll * float(WEIGHTS[i]))
_apply_local_rotation(skeleton, gx, idx, rot, pre_ik_globals)
## Same shape as ChestIK/HeadIK: convert a WORLD rotation into the bone's parent
## frame using the pre-IK snapshot, so this cannot feed back on itself.
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)