feat(avatar): Update idle animations and add gesture/bone definitions for enhanced realism

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-28 21:13:48 -07:00
parent 22d907c56f
commit 4496e5af32
5 changed files with 510 additions and 203 deletions

View file

@ -0,0 +1,173 @@
extends RefCounted
## Skeleton bone registry with automatic twist bone propagation and anatomical constraints.
## Wraps Skeleton3D so consumers just say: set_rotation("RightUpperArm", Vector3(75, 0, 0))
## and twist bones, constraints, and rest-pose math are handled internally.
const BodyConstraints = preload("res://src/data/body_constraints.gd")
const NodeUtilsScript = preload("res://src/core/node_utils.gd")
var _skeleton: Skeleton3D
var _entries: Dictionary = {} # bone_name -> {idx, rest_rot, twist_bones, constraint_key}
## bone_idx -> true (fast lookup during twist discovery)
var _registered_indices: Dictionary = {}
func setup(skeleton: Skeleton3D) -> void:
_skeleton = skeleton
func register(bone_name: String) -> bool:
## Register a VRM bone by name. Twist bones are discovered lazily on first use.
## Returns true if bone was found and registered.
if _entries.has(bone_name):
return true
var idx := NodeUtilsScript.find_bone_case_insensitive(_skeleton, bone_name)
if idx == -1:
push_warning("BoneRegistry: Bone '%s' not found in skeleton" % bone_name)
return false
_entries[bone_name] = {
"idx": idx,
"rest_rot": _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion(),
"twist_bones": [], # populated by _rebuild_twist_bones()
"twist_axis": _compute_twist_axis(idx),
"constraint_key": _to_constraint_key(bone_name),
}
_registered_indices[idx] = true
return true
func has_bone(bone_name: String) -> bool:
return _entries.has(bone_name)
func get_rest_rotation(bone_name: String) -> Quaternion:
return _entries[bone_name]["rest_rot"]
func get_index(bone_name: String) -> int:
return _entries[bone_name]["idx"]
func set_rotation(bone_name: String, target: Quaternion) -> void:
## Set a bone's rotation and propagate only the axial (twist) component to twist bones.
## Uses swing-twist decomposition to avoid swing bleeding into intermediate bones.
var entry: Dictionary = _entries[bone_name]
_skeleton.set_bone_pose_rotation(entry["idx"], target)
if entry["twist_bones"].is_empty():
return
var delta: Quaternion = entry["rest_rot"].inverse() * target
var twist_axis: Vector3 = entry["twist_axis"]
var p: Vector3 = Vector3(delta.x, delta.y, delta.z).project(twist_axis)
var twist_only := Quaternion(p.x, p.y, p.z, delta.w).normalized()
for twist: Dictionary in entry["twist_bones"]:
_skeleton.set_bone_pose_rotation(
twist["idx"], twist["rest_rot"] * Quaternion.IDENTITY.slerp(twist_only, 0.5)
)
func set_euler_degrees(bone_name: String, angles_deg: Vector3) -> void:
## Set rotation from euler degrees (pitch=x, yaw=y, roll=z) relative to rest pose.
## Applies anatomical constraints if available.
var entry: Dictionary = _entries[bone_name]
var constrained := _apply_constraints(entry["constraint_key"], angles_deg)
var euler_rad := Vector3(
deg_to_rad(constrained.x),
deg_to_rad(constrained.y),
deg_to_rad(constrained.z),
)
var target: Quaternion = entry["rest_rot"] * Quaternion.from_euler(euler_rad)
set_rotation(bone_name, target)
func reset(bone_name: String) -> void:
## Reset bone and its twist bones to rest pose.
var entry: Dictionary = _entries[bone_name]
_skeleton.set_bone_pose_rotation(entry["idx"], entry["rest_rot"])
for twist: Dictionary in entry["twist_bones"]:
_skeleton.set_bone_pose_rotation(twist["idx"], twist["rest_rot"])
func reset_all() -> void:
## Reset every registered bone to rest.
for bone_name: String in _entries:
reset(bone_name)
func register_all_from_defs(defs: Dictionary) -> void:
## Two-pass registration:
## 1. Register all gesture bones (populates _registered_indices)
## 2. Discover twist bones (now all gesture bone indices are known)
for gdef: Dictionary in defs.values():
var bones: Dictionary = gdef.get("bones", {})
for bone_name: String in bones:
if bones[bone_name] is Vector3:
register(bone_name)
for osc: Dictionary in gdef.get("oscillations", []):
var bone_name: String = osc.get("bone", "")
if not bone_name.is_empty():
register(bone_name)
# Now all gesture bone indices are known — discover twist bones
_rebuild_twist_bones()
func _rebuild_twist_bones() -> void:
## (Re)discover twist bones for all registered bones.
## Must be called after all gesture bones are registered so index checks work.
for bone_name: String in _entries:
var entry: Dictionary = _entries[bone_name]
entry["twist_bones"] = _find_twist_bones(entry["idx"])
# ── Internal ─────────────────────────────────────────────────────────────────
func _compute_twist_axis(bone_idx: int) -> Vector3:
## The twist axis is the longitudinal direction of the bone — toward its first child.
## In local rest space this is the direction the bone "points along".
var children := _skeleton.get_bone_children(bone_idx)
if children.is_empty():
return Vector3.UP
var dir: Vector3 = _skeleton.get_bone_rest(children[0]).origin
if dir.is_zero_approx():
return Vector3.UP
return dir.normalized()
func _find_twist_bones(parent_idx: int) -> Array:
## Iterative traversal: collect all intermediate bones below parent,
## stopping at bones that are themselves registered gesture bones.
## Uses bone INDEX comparison (not name) to avoid VRM/skeleton name mismatch.
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):
if _registered_indices.has(child_idx):
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
func _apply_constraints(constraint_key: String, angles_deg: Vector3) -> Vector3:
if constraint_key.is_empty() or not BodyConstraints.ROTATION_LIMITS.has(constraint_key):
return angles_deg
var clamped := BodyConstraints.clamp_rotation(
constraint_key, angles_deg.y, angles_deg.x, angles_deg.z
)
return Vector3(clamped.y, clamped.x, clamped.z)
static func _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

View file

@ -0,0 +1 @@
uid://bkc27ukms8khr

View file

@ -0,0 +1,287 @@
extends RefCounted
## Registry for scaleable, parameterized gestures.
## Wraps BoneRegistry to provide high-level gesture operations:
## - Register gestures from static defs or at runtime
## - Play with intensity, speed, and side (left/right mirroring)
## - Compose gesture output (bone targets + old-style outputs) each frame
##
## Usage:
## gesture_reg.register("wave", {bones: {"RightUpperArm": Vector3(20,0,-80)}, ...})
## gesture_reg.play("wave", {intensity: 0.7, speed: 1.5, side: "right"})
## # Each frame:
## gesture_reg.update(delta)
## var targets := gesture_reg.get_bone_targets() # bone_name -> Quaternion
const BoneRegistryScript = preload("res://src/avatar/bone_registry.gd")
const GestureDefsScript = preload("res://src/data/gesture_defs.gd")
## VRM bone names that can be mirrored Left <-> Right.
const MIRROR_PREFIXES: Array[String] = [
"Upper",
"Lower",
"Hand",
"Shoulder",
"IndexProximal",
"IndexIntermediate",
"IndexDistal",
"MiddleProximal",
"MiddleIntermediate",
"MiddleDistal",
"RingProximal",
"RingIntermediate",
"RingDistal",
"LittleProximal",
"LittleIntermediate",
"LittleDistal",
"ThumbProximal",
"ThumbIntermediate",
"ThumbDistal",
"UpperLeg",
"LowerLeg",
"Foot",
"Toes",
]
var _defs: Dictionary = {} # name -> gesture definition dict
var _bone_reg: RefCounted # BoneRegistry
# ── Active gesture state ──
var _active: String = ""
var _progress: float = 0.0
var _duration: float = 0.0
var _intensity: float = 1.0
var _speed: float = 1.0
var _side: float = 1.0 # 1.0 or -1.0 for use_side gestures
var _time: float = 0.0
# ── Outputs ──
var _bone_targets: Dictionary = {} # bone_name -> Quaternion
var _flat_outputs: Dictionary = {} # old-style key -> float
# ── Cooldowns ──
var _cooldowns: Dictionary = {} # gesture_name -> seconds remaining
func setup(bone_reg: RefCounted) -> void:
_bone_reg = bone_reg
var base_defs := GestureDefsScript.create()
for gname: String in base_defs:
register(gname, base_defs[gname])
func register(gesture_name: String, def: Dictionary) -> void:
## Register a gesture definition. Automatically registers referenced bones
## and rebuilds twist bone discovery for all registered bones.
_defs[gesture_name] = def
var new_bones := false
var bones: Dictionary = def.get("bones", {})
for bone_name: String in bones:
if bones[bone_name] is Vector3 and not _bone_reg.has_bone(bone_name):
_bone_reg.register(bone_name)
new_bones = true
for osc: Dictionary in def.get("oscillations", []):
var bone_name: String = osc.get("bone", "")
if not bone_name.is_empty() and not _bone_reg.has_bone(bone_name):
_bone_reg.register(bone_name)
new_bones = true
if new_bones:
_bone_reg._rebuild_twist_bones()
# Initialize cooldown
if not _cooldowns.has(gesture_name):
var cd: Vector2 = def.get("cooldown", Vector2(999.0, 999.0))
_cooldowns[gesture_name] = randf_range(cd.x * 0.3, cd.x * 0.6)
func has_gesture(gesture_name: String) -> bool:
return _defs.has(gesture_name)
func get_names() -> Array:
return _defs.keys()
func get_def(gesture_name: String) -> Dictionary:
return _defs.get(gesture_name, {})
func play(gesture_name: String, params: Dictionary = {}) -> void:
## Play a gesture with optional parameters:
## intensity: 0.0-1.0 (default 1.0) — scales all bone angles
## speed: multiplier (default 1.0) — faster/slower playback
## side: "left", "right", or "random" (default "random") — for mirrored gestures
if not _defs.has(gesture_name):
return
# Mirror the gesture if side=left and gesture uses Right bones
var requested_side: String = params.get("side", "random")
var mirror := requested_side == "left"
var actual_name := gesture_name
if mirror:
actual_name = _get_or_create_mirror(gesture_name)
var def: Dictionary = _defs[actual_name]
_active = actual_name
_duration = def["duration"]
_progress = 0.0
_intensity = clampf(float(params.get("intensity", 1.0)), 0.0, 2.0)
_speed = maxf(float(params.get("speed", 1.0)), 0.1)
if def.get("use_side", false):
match requested_side:
"left":
_side = -1.0
"right":
_side = 1.0
_:
_side = 1.0 if randf() > 0.5 else -1.0
else:
_side = 1.0
var cd: Vector2 = def.get("cooldown", Vector2(999.0, 999.0))
_cooldowns[actual_name] = randf_range(cd.x, cd.y)
if actual_name != gesture_name:
_cooldowns[gesture_name] = _cooldowns[actual_name]
func stop() -> void:
## Stop active gesture and reset bones.
for bone_name: String in _bone_targets:
if _bone_reg.has_bone(bone_name):
_bone_reg.reset(bone_name)
_bone_targets.clear()
_flat_outputs.clear()
_active = ""
func is_playing() -> bool:
return not _active.is_empty()
func get_active() -> String:
return _active
func update(delta: float, global_time: float) -> void:
## Advance gesture state. Call once per frame.
_time = global_time
if not _active.is_empty():
_progress += delta * _speed
if _progress >= _duration:
stop()
return
_compute()
func tick_cooldowns(delta: float) -> String:
## Tick cooldown timers. Returns gesture name if one is ready, empty otherwise.
for gname: String in _cooldowns:
_cooldowns[gname] -= delta
if _cooldowns[gname] <= 0.0:
return gname
return ""
func get_bone_targets() -> Dictionary:
return _bone_targets
func get_flat_outputs() -> Dictionary:
return _flat_outputs
# ── Mirroring ────────────────────────────────────────────────────────────────
func _get_or_create_mirror(gesture_name: String) -> String:
var mirror_name := gesture_name + "_mirror_l"
if _defs.has(mirror_name):
return mirror_name
if not _defs.has(gesture_name):
return gesture_name
var orig: Dictionary = _defs[gesture_name]
var mirrored := orig.duplicate(true)
# Mirror bone names: Right <-> Left
var orig_bones: Dictionary = orig.get("bones", {})
var new_bones: Dictionary = {}
for bone_name: String in orig_bones:
var val: Variant = orig_bones[bone_name]
var mirrored_name := _mirror_bone_name(bone_name)
if val is Vector3:
# Mirror Y and Z axes (yaw and roll flip sign)
new_bones[mirrored_name] = Vector3(val.x, -val.y, -val.z)
else:
new_bones[mirrored_name] = val
mirrored["bones"] = new_bones
# Mirror oscillation bones
var orig_oscs: Array = orig.get("oscillations", [])
var new_oscs: Array = []
for osc: Dictionary in orig_oscs:
var m_osc := osc.duplicate(true)
m_osc["bone"] = _mirror_bone_name(osc.get("bone", ""))
new_oscs.append(m_osc)
mirrored["oscillations"] = new_oscs
register(mirror_name, mirrored)
return mirror_name
static func _mirror_bone_name(bone_name: String) -> String:
if bone_name.begins_with("Right"):
return "Left" + bone_name.substr(5)
if bone_name.begins_with("Left"):
return "Right" + bone_name.substr(4)
return bone_name
# ── Computation ──────────────────────────────────────────────────────────────
func _compute() -> void:
var def: Dictionary = _defs[_active]
var env := _envelope(_progress / _duration)
var scaled_env := env * _intensity
var side := _side if def.get("use_side", false) else 1.0
var bones: Dictionary = def.get("bones", {})
_flat_outputs.clear()
_bone_targets.clear()
for bone_key: String in bones:
var val: Variant = bones[bone_key]
if val is Vector3:
if not _bone_reg.has_bone(bone_key):
continue
var scaled: Vector3 = val * scaled_env * side
var rest_rot: Quaternion = _bone_reg.get_rest_rotation(bone_key)
var euler_rad := Vector3(
deg_to_rad(scaled.x),
deg_to_rad(scaled.y),
deg_to_rad(scaled.z),
)
_bone_targets[bone_key] = rest_rot * Quaternion.from_euler(euler_rad)
else:
_flat_outputs[bone_key] = float(val) * scaled_env * side
for osc: Dictionary in def.get("oscillations", []):
var bone_name: String = osc["bone"]
if not _bone_reg.has_bone(bone_name):
continue
var rest_rot: Quaternion = _bone_reg.get_rest_rotation(bone_name)
var clipped := clampf(scaled_env, 0.0, 1.0)
var wave_rad := sin(_time * osc["freq"] * TAU) * deg_to_rad(osc["amp_deg"]) * clipped
var osc_delta := Quaternion(osc["axis"], wave_rad)
var base: Quaternion = _bone_targets.get(bone_name, rest_rot)
_bone_targets[bone_name] = base * osc_delta
static func _envelope(t: float) -> float:
## Smooth ramp-up, hold, smooth ramp-down.
if t < 0.2:
return smoothstep(0.0, 1.0, t / 0.2)
if t < 0.7:
return 1.0
return smoothstep(0.0, 1.0, (1.0 - t) / 0.3)

View file

@ -0,0 +1 @@
uid://tn4o3f3shgdu

View file

@ -2,10 +2,12 @@ extends Node
## Procedural idle animation for VRM avatars.
## Breathing, blinking, sway, head micro-movements, and data-driven gestures.
## Layers additively on top of AnimationStateMachine postures.
## Delegates gesture logic to GestureRegistry and bone control to BoneRegistry.
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")
const BoneRegistryScript = preload("res://src/avatar/bone_registry.gd")
const GestureRegistryScript = preload("res://src/avatar/gesture_registry.gd")
# ── Tuning ──
const BREATH_CYCLE := 3.0
@ -20,15 +22,16 @@ const HEAD_AMP := Vector3(0.02, 0.015, 0.012)
const HEAD_MOVE_INTERVAL := Vector2(4.0, 10.0)
const HEAD_MOVE_DUR := 1.5
# ── Gesture definitions (data-driven, from gesture_defs.gd) ──
var gesture_defs: Dictionary = GestureDefsScript.create()
# ── Registries (public — accessible by tray_listener, test_pose, etc.) ──
var bone_reg: RefCounted # BoneRegistry
var gesture_reg: RefCounted # GestureRegistry
# ── Skeleton refs ──
var _skeleton: Skeleton3D
var _mesh: MeshInstance3D
var _time: float = 0.0
# Bone indices
# Bone indices (idle animation bones — NOT managed by bone_reg)
var _chest_idx: int = -1
var _upper_chest_idx: int = -1
var _hips_idx: int = -1
@ -44,9 +47,6 @@ var _hips_rest_rot: Quaternion
var _head_rest_rot: Quaternion
var _shoulder_l_rest_rot: Quaternion
var _shoulder_r_rest_rot: Quaternion
# Generic bone registry — auto-populated from gesture_defs at setup
var _bone_reg: Dictionary = {}
var _gesture_bone_targets: Dictionary = {}
# ── Blink state ──
var _blink_timer: float = 0.0
@ -68,14 +68,6 @@ var _breath_offset: float = 0.0
var _sway_x: float = 0.0
var _sway_z: float = 0.0
# ── Gesture state ──
var _gesture_active: String = ""
var _gesture_progress: float = 0.0
var _gesture_duration: float = 0.0
var _gesture_side: float = 1.0
var _gesture_cooldowns: Dictionary = {}
var _gesture_outputs: Dictionary = {}
func setup(model: Node3D) -> void:
_skeleton = NodeUtilsScript.find_child_of_type(model, "Skeleton3D") as Skeleton3D
@ -110,17 +102,17 @@ func setup(model: Node3D) -> void:
_skeleton.get_bone_rest(_shoulder_r_idx).basis.get_rotation_quaternion()
)
_register_gesture_bones()
# Initialize registries
bone_reg = BoneRegistryScript.new()
bone_reg.setup(_skeleton)
gesture_reg = GestureRegistryScript.new()
gesture_reg.setup(bone_reg)
if _mesh != null:
_blink_idx = NodeUtilsScript.find_blend_shape_index(_mesh, "Blink")
if _blink_idx == -1:
_blink_idx = NodeUtilsScript.find_blend_shape_index(_mesh, "blink")
for gname: String in gesture_defs:
var cd: Vector2 = gesture_defs[gname]["cooldown"]
_gesture_cooldowns[gname] = randf_range(cd.x * 0.3, cd.x * 0.6)
_next_blink = randf_range(BLINK_MIN, BLINK_MAX)
_next_head_move = randf_range(HEAD_MOVE_INTERVAL.x, HEAD_MOVE_INTERVAL.y)
EventBus.gesture_requested.connect(_on_gesture_requested)
@ -198,55 +190,51 @@ func _pick_head_move() -> Vector3:
func _do_gestures(delta: float) -> void:
if not _gesture_active.is_empty():
_gesture_progress += delta
if _gesture_progress >= _gesture_duration:
if gesture_reg.is_playing():
var active: String = gesture_reg.get_active()
gesture_reg.update(delta, _time)
if not gesture_reg.is_playing():
# Gesture just finished
(
FlightRecorder
. record(
"gesture.finished",
"Gesture '%s' finished" % _gesture_active,
{"gesture": _gesture_active},
"Gesture '%s' finished" % active,
{"gesture": active},
)
)
_reset_gesture_bones()
_gesture_active = ""
else:
_compute_gesture_outputs()
return
for gname: String in _gesture_cooldowns:
_gesture_cooldowns[gname] -= delta
if _gesture_cooldowns[gname] <= 0.0:
_start_gesture(gname)
return
# Auto-trigger from cooldowns
var ready: String = gesture_reg.tick_cooldowns(delta)
if not ready.is_empty():
_play_gesture(ready)
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 _play_gesture(gname: String, params: Dictionary = {}) -> void:
var def: Dictionary = gesture_reg.get_def(gname)
if def.is_empty():
return
if gesture_reg.is_playing():
(
FlightRecorder
. record(
"gesture.interrupted",
"Gesture '%s' interrupted by '%s'" % [gesture_reg.get_active(), gname],
{"interrupted": gesture_reg.get_active(), "by": gname},
)
)
gesture_reg.stop()
func _start_gesture(gname: String) -> void:
var def: Dictionary = gesture_defs[gname]
_gesture_active = gname
_gesture_duration = def["duration"]
_gesture_progress = 0.0
_gesture_side = 1.0 if randf() > 0.5 else -1.0
_gesture_cooldowns[gname] = randf_range(def["cooldown"].x, def["cooldown"].y)
gesture_reg.play(gname, params)
(
FlightRecorder
. record(
"gesture.started",
"Gesture '%s' started" % gname,
{"gesture": gname, "duration": _gesture_duration},
{"gesture": gname, "duration": def.get("duration", 0.0), "params": str(params)},
)
)
@ -259,60 +247,11 @@ func _start_gesture(gname: String) -> void:
_blink_timer = _next_blink
if def.get("disengage_gaze", false):
EventBus.gaze_disengage.emit(_gesture_duration * 0.8)
func _compute_gesture_outputs() -> void:
var def: Dictionary = gesture_defs[_gesture_active]
var env := _envelope_gesture(_gesture_progress / _gesture_duration)
var side := _gesture_side if def.get("use_side", false) else 1.0
var bones: Dictionary = def.get("bones", {})
_gesture_outputs.clear()
_gesture_bone_targets.clear()
for bone_key: String in bones:
var val: Variant = bones[bone_key]
if val is Vector3:
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(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))
else:
_gesture_outputs[bone_key] = float(val) * env * side
var oscillations: Array = def.get("oscillations", [])
for osc: Dictionary in oscillations:
var bone_name: String = osc["bone"]
if not _bone_reg.has(bone_name):
continue
var reg: Dictionary = _bone_reg[bone_name]
var clipped_env := clampf(env, 0.0, 1.0)
var wave_rad := sin(_time * osc["freq"] * TAU) * deg_to_rad(osc["amp_deg"]) * clipped_env
var osc_delta := Quaternion(osc["axis"], wave_rad)
var base: Quaternion = _gesture_bone_targets.get(bone_name, reg["rest_rot"])
_gesture_bone_targets[bone_name] = base * osc_delta
EventBus.gaze_disengage.emit(def.get("duration", 2.0) * 0.8)
func _apply_bones() -> void:
var g := _gesture_outputs
var g: Dictionary = gesture_reg.get_flat_outputs()
if _chest_idx != -1:
var pos := _chest_rest_pos
@ -327,7 +266,6 @@ 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]))
@ -343,7 +281,6 @@ 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]))
@ -382,23 +319,16 @@ func _apply_bones() -> void:
)
)
for bone_name: String in _gesture_bone_targets:
if _bone_reg.has(bone_name):
var reg: Dictionary = _bone_reg[bone_name]
var target: Quaternion = _gesture_bone_targets[bone_name]
_skeleton.set_bone_pose_rotation(reg["idx"], target)
# Propagate to twist/helper bones so mesh doesn't tear
var delta: Quaternion = reg["rest_rot"].inverse() * target
for twist: Dictionary in reg["twist_bones"]:
_skeleton.set_bone_pose_rotation(
twist["idx"], twist["rest_rot"] * delta.slerp(Quaternion.IDENTITY, 0.5)
)
# Apply gesture bone targets via bone_reg (handles twist propagation)
for bone_name: String in gesture_reg.get_bone_targets():
if bone_reg.has_bone(bone_name):
bone_reg.set_rotation(bone_name, gesture_reg.get_bone_targets()[bone_name])
if _blink_idx != -1 and _mesh != null:
_mesh.set_blend_shape_value(_blink_idx, _blink_weight)
func _envelope_bell(t: float) -> float:
static func _envelope_bell(t: float) -> float:
if t < 0.3:
return smoothstep(0.0, 1.0, t / 0.3)
if t < 0.6:
@ -406,95 +336,10 @@ func _envelope_bell(t: float) -> float:
return smoothstep(0.0, 1.0, (1.0 - t) / 0.4)
func _envelope_gesture(t: float) -> float:
if t < 0.2:
return smoothstep(0.0, 1.0, t / 0.2)
if t < 0.7:
return 1.0
return smoothstep(0.0, 1.0, (1.0 - t) / 0.3)
func _register_gesture_bones() -> void:
for gdef: Dictionary in gesture_defs.values():
var bones: Dictionary = gdef.get("bones", {})
for bone_name: String in bones:
if bones[bone_name] is Vector3 and not _bone_reg.has(bone_name):
_register_bone(bone_name)
var oscillations: Array = gdef.get("oscillations", [])
for osc: Dictionary in oscillations:
var bone_name: String = osc["bone"]
if not _bone_reg.has(bone_name):
_register_bone(bone_name)
func _register_bone(bone_name: String) -> void:
var idx := NodeUtilsScript.find_bone_case_insensitive(_skeleton, bone_name)
if idx == -1:
push_warning("IdleAnimator: Bone '%s' not found" % bone_name)
return
var rest_rot := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion()
_bone_reg[bone_name] = {
"idx": idx,
"rest_rot": rest_rot,
"twist_bones": _find_twist_bones(idx),
}
func _find_twist_bones(parent_idx: int) -> Array:
## 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
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:
if gname == "slow_blink":
_blink_timer = _next_blink
return
if not gesture_defs.has(gname):
if not gesture_reg.has_gesture(gname):
return
if not _gesture_active.is_empty():
(
FlightRecorder
. record(
"gesture.interrupted",
"Gesture '%s' interrupted by '%s'" % [_gesture_active, gname],
{"interrupted": _gesture_active, "by": gname},
)
)
_reset_gesture_bones()
_gesture_active = ""
_start_gesture(gname)
_play_gesture(gname)