diff --git a/shared/godot/avatar/bone_registry.gd b/shared/godot/avatar/bone_registry.gd new file mode 100644 index 0000000..0aaa219 --- /dev/null +++ b/shared/godot/avatar/bone_registry.gd @@ -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 diff --git a/shared/godot/avatar/bone_registry.gd.uid b/shared/godot/avatar/bone_registry.gd.uid new file mode 100644 index 0000000..6bbc896 --- /dev/null +++ b/shared/godot/avatar/bone_registry.gd.uid @@ -0,0 +1 @@ +uid://bkc27ukms8khr diff --git a/shared/godot/avatar/gesture_registry.gd b/shared/godot/avatar/gesture_registry.gd new file mode 100644 index 0000000..2b04306 --- /dev/null +++ b/shared/godot/avatar/gesture_registry.gd @@ -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) diff --git a/shared/godot/avatar/gesture_registry.gd.uid b/shared/godot/avatar/gesture_registry.gd.uid new file mode 100644 index 0000000..aefbb8b --- /dev/null +++ b/shared/godot/avatar/gesture_registry.gd.uid @@ -0,0 +1 @@ +uid://tn4o3f3shgdu diff --git a/shared/godot/avatar/idle_animator.gd b/shared/godot/avatar/idle_animator.gd index 9a19ad3..cfe7c58 100644 --- a/shared/godot/avatar/idle_animator.gd +++ b/shared/godot/avatar/idle_animator.gd @@ -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)