refactor(avatar): ♻️ Implement modular gaze and idle animation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-28 15:46:39 -07:00
parent 78f2a4969f
commit 74f9134d48
2 changed files with 96 additions and 74 deletions

View file

@ -30,9 +30,14 @@ const NECK_YAW_SCALE: float = 0.30 # fraction of gaze_x applied to neck yaw
const BODY_SMOOTHING: float = 2.0 # lerp speed for body turn-in (slower than eyes)
const NECK_YAW_SMOOTHING: float = 4.0 # lerp speed for neck yaw (between body and eyes)
## Anatomical yaw limits (rad) sourced from BodyConstraints at init.
## Anatomical limits (rad) sourced from BodyConstraints at init.
var _hips_yaw_max: float = 0.0
var _neck_yaw_max: float = 0.0
var _neck_pitch_max: float = 0.0
var _eye_yaw_max: float = 0.0
var _eye_pitch_max: float = 0.0
var _eye_yaw_speed: float = 0.0
var _eye_pitch_speed: float = 0.0
var _skeleton: Skeleton3D
var _left_eye_idx: int = -1
@ -71,6 +76,11 @@ func setup(model: Node3D, _camera: Camera3D) -> void:
_hips_yaw_max = deg_to_rad(BodyConstraints.ROTATION_LIMITS["hips"]["yaw"][1])
_neck_yaw_max = deg_to_rad(BodyConstraints.ROTATION_LIMITS["neck"]["yaw"][1])
_neck_pitch_max = deg_to_rad(BodyConstraints.ROTATION_LIMITS["neck"]["pitch"][1])
_eye_yaw_max = deg_to_rad(BodyConstraints.ROTATION_LIMITS["left_eye"]["yaw"][1])
_eye_pitch_max = deg_to_rad(BodyConstraints.ROTATION_LIMITS["left_eye"]["pitch"][1])
_eye_yaw_speed = deg_to_rad(BodyConstraints.VELOCITY_LIMITS["left_eye"]["yaw"])
_eye_pitch_speed = deg_to_rad(BodyConstraints.VELOCITY_LIMITS["left_eye"]["pitch"])
_left_eye_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "LeftEye")
_right_eye_idx = NodeUtilsScript.find_bone_case_insensitive(_skeleton, "RightEye")
@ -258,7 +268,13 @@ func _smooth_gaze(delta: float) -> void:
weight = GAZE_SMOOTHING_RETURN
else:
weight = GAZE_SMOOTHING
_current_gaze = _current_gaze.lerp(_target_gaze, weight * delta)
var new_gaze := _current_gaze.lerp(_target_gaze, weight * delta)
# Clamp per-frame angular velocity to body-api eye speed limits
var dx := clampf(new_gaze.x - _current_gaze.x, -_eye_yaw_speed * delta, _eye_yaw_speed * delta)
var dy := clampf(
new_gaze.y - _current_gaze.y, -_eye_pitch_speed * delta, _eye_pitch_speed * delta
)
_current_gaze = Vector2(_current_gaze.x + dx, _current_gaze.y + dy)
if _returning_from_disengage and _current_gaze.distance_to(_target_gaze) < 0.05:
_returning_from_disengage = false
@ -317,6 +333,8 @@ func _apply_neck() -> void:
# AnimStateMachine ran earlier this frame, so get_bone_pose_rotation gives its value.
var base := _skeleton.get_bone_pose_rotation(_neck_idx)
var gy := -_current_gaze.y * NECK_INFLUENCE if in_face_mode else 0.0
# Clamp neck pitch to anatomical limits from @lilith/body-api
gy = clampf(gy, -_neck_pitch_max, _neck_pitch_max)
(
_skeleton
. set_bone_pose_rotation(
@ -376,7 +394,10 @@ func _apply_bone_fallback(
gx: float,
gy: float,
) -> void:
var rot := Quaternion(Vector3.UP, gx * 0.3) * Quaternion(Vector3.RIGHT, gy * 0.2)
# Clamp to anatomical eye limits from @lilith/body-api
var yaw := clampf(gx * _eye_yaw_max, -_eye_yaw_max, _eye_yaw_max)
var pitch := clampf(gy * _eye_pitch_max, -_eye_pitch_max, _eye_pitch_max)
var rot := Quaternion(Vector3.UP, yaw) * Quaternion(Vector3.RIGHT, pitch)
_skeleton.set_bone_pose_rotation(bone_idx, rest * rot)

View file

@ -5,6 +5,7 @@ extends Node
const NodeUtilsScript = preload("res://src/core/node_utils.gd")
const GestureDefsScript = preload("res://src/data/gesture_defs.gd")
const BodyConstraints = preload("res://src/data/body_constraints.gd")
# ── Tuning ──
const BREATH_CYCLE := 3.0
@ -137,16 +138,10 @@ func _process(delta: float) -> void:
_apply_bones()
# ━━━ Breathing ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _do_breathing() -> void:
_breath_offset = sin(_time * TAU / BREATH_CYCLE) * BREATH_AMP
# ━━━ Blinking ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _do_blink(delta: float) -> void:
if _blink_progress >= 0.0:
_blink_progress += delta
@ -166,17 +161,11 @@ func _do_blink(delta: float) -> void:
_blink_progress = 0.0
# ━━━ Sway ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _do_sway() -> void:
_sway_x = sin(_time * SWAY_SPEED) * SWAY_AMP
_sway_z = sin(_time * SWAY_SPEED * 0.7 + 1.0) * SWAY_AMP
# ━━━ Head micro-movements ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _do_head_micro(delta: float) -> void:
var mp := sin(_time * HEAD_SPEED.x) * HEAD_AMP.x
var my := sin(_time * HEAD_SPEED.y + 2.0) * HEAD_AMP.y
@ -208,9 +197,6 @@ func _pick_head_move() -> Vector3:
return Vector3(0.0, 0.05 * s, -0.025 * s)
# ━━━ Data-driven gestures ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _do_gestures(delta: float) -> void:
if not _gesture_active.is_empty():
_gesture_progress += delta
@ -223,14 +209,7 @@ func _do_gestures(delta: float) -> void:
{"gesture": _gesture_active},
)
)
for bone_name: String in _gesture_bone_targets:
if _bone_reg.has(bone_name):
var reg: Dictionary = _bone_reg[bone_name]
_skeleton.set_bone_pose_rotation(reg["idx"], reg["rest_rot"])
for twist: Dictionary in reg["twist_bones"]:
_skeleton.set_bone_pose_rotation(twist["idx"], twist["rest_rot"])
_gesture_bone_targets.clear()
_gesture_outputs.clear()
_reset_gesture_bones()
_gesture_active = ""
else:
_compute_gesture_outputs()
@ -243,6 +222,17 @@ func _do_gestures(delta: float) -> void:
return
func _reset_gesture_bones() -> void:
for bone_name: String in _gesture_bone_targets:
if _bone_reg.has(bone_name):
var reg: Dictionary = _bone_reg[bone_name]
_skeleton.set_bone_pose_rotation(reg["idx"], reg["rest_rot"])
for twist: Dictionary in reg["twist_bones"]:
_skeleton.set_bone_pose_rotation(twist["idx"], twist["rest_rot"])
_gesture_bone_targets.clear()
_gesture_outputs.clear()
func _start_gesture(gname: String) -> void:
var def: Dictionary = gesture_defs[gname]
_gesture_active = gname
@ -287,10 +277,21 @@ func _compute_gesture_outputs() -> void:
if not _bone_reg.has(bone_key):
continue
var angles_deg: Vector3 = val
var scaled := Vector3(
angles_deg.x * env * side,
angles_deg.y * env * side,
angles_deg.z * env * side,
)
# Clamp to anatomical limits via @lilith/body-api
var part := _bone_to_constraint_key(bone_key)
if not part.is_empty() and BodyConstraints.ROTATION_LIMITS.has(part):
# Vector3 convention: x=pitch, y=yaw, z=roll
var clamped := BodyConstraints.clamp_rotation(part, scaled.y, scaled.x, scaled.z)
scaled = Vector3(clamped.y, clamped.x, clamped.z)
var euler_rad := Vector3(
deg_to_rad(angles_deg.x) * env * side,
deg_to_rad(angles_deg.y) * env * side,
deg_to_rad(angles_deg.z) * env * side,
deg_to_rad(scaled.x),
deg_to_rad(scaled.y),
deg_to_rad(scaled.z),
)
var reg: Dictionary = _bone_reg[bone_key]
_gesture_bone_targets[bone_key] = (reg["rest_rot"] * Quaternion.from_euler(euler_rad))
@ -310,9 +311,6 @@ func _compute_gesture_outputs() -> void:
_gesture_bone_targets[bone_name] = base * osc_delta
# ━━━ Bone application (single write point per bone) ━━━━━━━━━━━━━━━━━━━━━━━
func _apply_bones() -> void:
var g := _gesture_outputs
@ -329,6 +327,10 @@ func _apply_bones() -> void:
if _hips_idx != -1:
var rx: float = _sway_x + g.get("hips_x", 0.0)
var rz: float = _sway_z + g.get("hips_z", 0.0)
# Clamp hips pitch/roll to anatomical limits (radians)
var hips_lim: Dictionary = BodyConstraints.ROTATION_LIMITS["hips"]
rx = clampf(rx, deg_to_rad(hips_lim["pitch"][0]), deg_to_rad(hips_lim["pitch"][1]))
rz = clampf(rz, deg_to_rad(hips_lim["roll"][0]), deg_to_rad(hips_lim["roll"][1]))
(
_skeleton
. set_bone_pose_rotation(
@ -341,6 +343,11 @@ func _apply_bones() -> void:
var p: float = _head_pitch + g.get("head_pitch", 0.0)
var y: float = _head_yaw + g.get("head_yaw", 0.0)
var r: float = _head_roll + g.get("head_roll", 0.0)
# Clamp head rotation to anatomical limits (radians)
var head_lim: Dictionary = BodyConstraints.ROTATION_LIMITS["head"]
p = clampf(p, deg_to_rad(head_lim["pitch"][0]), deg_to_rad(head_lim["pitch"][1]))
y = clampf(y, deg_to_rad(head_lim["yaw"][0]), deg_to_rad(head_lim["yaw"][1]))
r = clampf(r, deg_to_rad(head_lim["roll"][0]), deg_to_rad(head_lim["roll"][1]))
(
_skeleton
. set_bone_pose_rotation(
@ -391,9 +398,6 @@ func _apply_bones() -> void:
_mesh.set_blend_shape_value(_blink_idx, _blink_weight)
# ━━━ Envelopes ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _envelope_bell(t: float) -> float:
if t < 0.3:
return smoothstep(0.0, 1.0, t / 0.3)
@ -410,9 +414,6 @@ func _envelope_gesture(t: float) -> float:
return smoothstep(0.0, 1.0, (1.0 - t) / 0.3)
# ━━━ Bone registry ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
func _register_gesture_bones() -> void:
for gdef: Dictionary in gesture_defs.values():
var bones: Dictionary = gdef.get("bones", {})
@ -440,36 +441,43 @@ func _register_bone(bone_name: String) -> void:
func _find_twist_bones(parent_idx: int) -> Array:
## Find intermediate/twist child bones that aren't VRM humanoid bones.
## These need to follow their parent's rotation to prevent mesh tearing.
var twist_bones: Array = []
var children := _skeleton.get_bone_children(parent_idx)
for child_idx: int in children:
var child_name := _skeleton.get_bone_name(child_idx)
# Skip bones that are themselves registered gesture bones
if _bone_reg.has(child_name):
continue
# Skip bones that will be registered later (check all gesture defs)
var is_gesture_bone := false
for gdef: Dictionary in gesture_defs.values():
if gdef.get("bones", {}).has(child_name):
is_gesture_bone = true
break
for osc: Dictionary in gdef.get("oscillations", []):
if osc.get("bone", "") == child_name:
is_gesture_bone = true
break
if is_gesture_bone:
continue
# This is an intermediate bone — register it as a twist bone
twist_bones.append({
"idx": child_idx,
"rest_rot": _skeleton.get_bone_rest(child_idx).basis.get_rotation_quaternion(),
})
return twist_bones
## Collect intermediate bones between this gesture bone and the next (mesh continuity).
var result: Array = []
var stack: Array[int] = [parent_idx]
while not stack.is_empty():
var idx: int = stack.pop_back()
for child_idx: int in _skeleton.get_bone_children(idx):
var child_name := _skeleton.get_bone_name(child_idx)
if _bone_reg.has(child_name) or _is_gesture_bone(child_name):
continue
var rest_rot := _skeleton.get_bone_rest(child_idx).basis.get_rotation_quaternion()
result.append({"idx": child_idx, "rest_rot": rest_rot})
stack.push_back(child_idx)
return result
# ━━━ External triggers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
static func _bone_to_constraint_key(bone_name: String) -> String:
## PascalCase VRM bone → snake_case body-api key.
var result := ""
for i: int in range(bone_name.length()):
var c := bone_name[i]
if c == c.to_upper() and c != c.to_lower():
if not result.is_empty():
result += "_"
result += c.to_lower()
else:
result += c
return result
func _is_gesture_bone(bone_name: String) -> bool:
for gdef: Dictionary in gesture_defs.values():
if gdef.get("bones", {}).has(bone_name):
return true
for osc: Dictionary in gdef.get("oscillations", []):
if osc.get("bone", "") == bone_name:
return true
return false
func _on_gesture_requested(gname: String) -> void:
@ -487,13 +495,6 @@ func _on_gesture_requested(gname: String) -> void:
{"interrupted": _gesture_active, "by": gname},
)
)
for bone_name: String in _gesture_bone_targets:
if _bone_reg.has(bone_name):
var reg: Dictionary = _bone_reg[bone_name]
_skeleton.set_bone_pose_rotation(reg["idx"], reg["rest_rot"])
for twist: Dictionary in reg["twist_bones"]:
_skeleton.set_bone_pose_rotation(twist["idx"], twist["rest_rot"])
_gesture_bone_targets.clear()
_gesture_outputs.clear()
_reset_gesture_bones()
_gesture_active = ""
_start_gesture(gname)