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:
parent
22d907c56f
commit
4496e5af32
5 changed files with 510 additions and 203 deletions
173
shared/godot/avatar/bone_registry.gd
Normal file
173
shared/godot/avatar/bone_registry.gd
Normal 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
|
||||
1
shared/godot/avatar/bone_registry.gd.uid
Normal file
1
shared/godot/avatar/bone_registry.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bkc27ukms8khr
|
||||
287
shared/godot/avatar/gesture_registry.gd
Normal file
287
shared/godot/avatar/gesture_registry.gd
Normal 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)
|
||||
1
shared/godot/avatar/gesture_registry.gd.uid
Normal file
1
shared/godot/avatar/gesture_registry.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://tn4o3f3shgdu
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue