So I've been working at this for about a whole day and one morning. Finally, I can present my BoneLookAt script!
It supports a custom offset rotation as well as minimum and maximum rotation limits:
@tool
class_name BoneLookAt extends Marker3D
@export var bone_name:String
@export var interpolation:float = 1.0
@export var use_external_skeleton := false
@export var external_skeleton:NodePath
@export var follow_target:NodePath
@export var follow_threshold_degrees:float = 15
@export var offset_rotation:= Vector3(0, 0, 0)
@export var min_rotation := Vector3(-180, -180, -180)
@export var max_rotation := Vector3(180, 180, 180)
@export var limits_enabled:bool = false
@export var enabled := false:
set(e):
if enabled and not e:
enabled = e
should_reset = true
else:
enabled = e
var should_reset := false
var last_debug:int = 0
func get_skeleton() -> Skeleton3D:
if use_external_skeleton:
return get_node(external_skeleton)
else:
return get_parent()
func should_follow_target(current_dir: Vector3, target_dir: Vector3) -> bool:
var angle = acos(current_dir.normalized().dot(target_dir.normalized())) * 180 / PI
return angle > follow_threshold_degrees
func _move_towards_target(weight:float = 0.1):
var target = get_node(follow_target)
if target:
global_position = lerp(global_position, target.global_position, weight)
func to_radians(euler:Vector3):
return Vector3(deg_to_rad(euler.x), deg_to_rad(euler.y), deg_to_rad(euler.z))
func _process(delta):
var debug = false
var t = Time.get_ticks_msec()
if t - last_debug > 500:
debug = true
last_debug = t
var skeleton:Skeleton3D = get_skeleton()
if not skeleton:
return
var bone_idx:int = skeleton.find_bone(bone_name)
if bone_idx == -1:
return
var bone_pose : Transform3D = skeleton.get_bone_pose(bone_idx)
if not enabled:
if should_reset:
var global_pose_no_override = skeleton.get_bone_global_pose_no_override(bone_idx)
skeleton.set_bone_global_pose_override(bone_idx, global_pose_no_override, interpolation, false)
should_reset = false
return
var current_dir = bone_pose.origin.direction_to(global_position)
var target_dir = bone_pose.origin.direction_to(get_node(follow_target).global_position)
if follow_target:
if should_follow_target(current_dir, target_dir):
_move_towards_target()
else:
_move_towards_target(0.01)
var parent_bone_idx = skeleton.get_bone_parent(bone_idx)
if parent_bone_idx > -1:
var parent_global_pose = skeleton.get_bone_global_pose(parent_bone_idx)
bone_pose = parent_global_pose * bone_pose
var global_bone_pose = bone_pose
global_bone_pose = global_bone_pose.looking_at(skeleton.to_local(global_position), Vector3.UP, true)
# add rotation limits
if limits_enabled:
var euler:Vector3 = global_bone_pose.basis.get_euler()
var min_rot = to_radians(min_rotation)
var max_rot = to_radians(max_rotation)
if euler.x < min_rot.x:
euler.x = min_rot.x
elif euler.x > max_rot.x:
euler.x = max_rot.x
if euler.y < min_rot.y:
euler.y = min_rot.y
elif euler.y > max_rot.y:
euler.y = max_rot.y
if euler.z < min_rot.z:
euler.z = min_rot.z
elif euler.z > max_rot.z:
euler.z = max_rot.z
global_bone_pose.basis = Basis().from_euler(euler)
# add the offset rotation
global_bone_pose = global_bone_pose.rotated_local(Vector3(1, 0, 0), deg_to_rad(offset_rotation.x))
global_bone_pose = global_bone_pose.rotated_local(Vector3(0, 1, 0), deg_to_rad(offset_rotation.y))
global_bone_pose = global_bone_pose.rotated_local(Vector3(0, 0, 1), deg_to_rad(offset_rotation.z))
skeleton.set_bone_global_pose_override(bone_idx, global_bone_pose, interpolation, true)