refactor(avatar): ♻️ Implement modular gaze and idle animation logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
78f2a4969f
commit
74f9134d48
2 changed files with 96 additions and 74 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue