From c092ed16281c4e7ca65ed5ac8f35e6917da5caa0 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 20:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(win):=20T-WIN-006=20=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E7=82=B9=20=E2=86=92=20VRM=20=E9=AA=A8=E9=AA=BC=E9=87=8D?= =?UTF-8?q?=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/anim/skeleton_retarget.gd | 220 +++++++++++++++++++++++ src/anim/skeleton_retarget.gd.uid | 1 + tests/test_retarget.gd | 281 ++++++++++++++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 src/anim/skeleton_retarget.gd create mode 100644 src/anim/skeleton_retarget.gd.uid create mode 100644 tests/test_retarget.gd diff --git a/src/anim/skeleton_retarget.gd b/src/anim/skeleton_retarget.gd new file mode 100644 index 0000000..7542330 --- /dev/null +++ b/src/anim/skeleton_retarget.gd @@ -0,0 +1,220 @@ +class_name SkeletonRetarget +extends RefCounted + +## T-WIN-006:MediaPipe 33 点 → VRM 骨骼重定向 +## +## 算法(方向匹配 + 保留扭转): +## 1. 为每根被驱动骨骼指定一条「骨链」:(起点关键点, 终点关键点) +## 2. 取骨骼静止时的世界朝向 rest_dir(由该骨与首个子骨的位置差得出, +## 不假设模型采用哪种骨骼轴向约定) +## 3. 目标朝向 target_dir 由两个关键点在世界空间的方向得出 +## 4. delta = rest_dir → target_dir 的最小旋转四元数 +## 5. 目标全局朝向 desired = Basis(delta) × rest_global_basis +## 6. 写回局部姿态旋转:pose = parent_global_basis⁻¹ × desired +## +## 第 6 步的公式由真实 VRM(VRoid 154 骨骼模型)实测标定, +## 误差 1.9e-8(见 tests/test_retarget.gd 的回归断言)—— +## Godot 文档未写明 rest/pose 的合成顺序,因此这里以实测为准。 + +const KeypointFrame = preload("res://src/net/keypoint_frame.gd") + +## 虚拟关节索引(与 contracts/skeleton-topology.md §2 一致) +const VIRTUAL_NECK := 33 +const VIRTUAL_PELVIS := 34 + +## 骨链定义:canonical 骨骼名 → [起点点索引, 终点点索引] +## 顺序即层级(父在前),保证写入姿态时父级已更新。 +const BONE_CHAINS := { + "hips": [VIRTUAL_PELVIS, VIRTUAL_NECK], + "spine": [VIRTUAL_PELVIS, VIRTUAL_NECK], + "chest": [VIRTUAL_PELVIS, VIRTUAL_NECK], + "upperChest": [VIRTUAL_PELVIS, VIRTUAL_NECK], + "neck": [VIRTUAL_NECK, KeypointFrame.NOSE], + "head": [VIRTUAL_NECK, KeypointFrame.NOSE], + "leftShoulder": [VIRTUAL_NECK, KeypointFrame.LEFT_SHOULDER], + "leftUpperArm": [KeypointFrame.LEFT_SHOULDER, KeypointFrame.LEFT_ELBOW], + "leftLowerArm": [KeypointFrame.LEFT_ELBOW, KeypointFrame.LEFT_WRIST], + "leftHand": [KeypointFrame.LEFT_WRIST, KeypointFrame.LEFT_INDEX], + "rightShoulder": [VIRTUAL_NECK, KeypointFrame.RIGHT_SHOULDER], + "rightUpperArm": [KeypointFrame.RIGHT_SHOULDER, KeypointFrame.RIGHT_ELBOW], + "rightLowerArm": [KeypointFrame.RIGHT_ELBOW, KeypointFrame.RIGHT_WRIST], + "rightHand": [KeypointFrame.RIGHT_WRIST, KeypointFrame.RIGHT_INDEX], + "leftUpperLeg": [KeypointFrame.LEFT_HIP, KeypointFrame.LEFT_KNEE], + "leftLowerLeg": [KeypointFrame.LEFT_KNEE, KeypointFrame.LEFT_ANKLE], + "leftFoot": [KeypointFrame.LEFT_ANKLE, KeypointFrame.LEFT_FOOT_INDEX], + "leftToes": [KeypointFrame.LEFT_ANKLE, KeypointFrame.LEFT_FOOT_INDEX], + "rightUpperLeg": [KeypointFrame.RIGHT_HIP, KeypointFrame.RIGHT_KNEE], + "rightLowerLeg": [KeypointFrame.RIGHT_KNEE, KeypointFrame.RIGHT_ANKLE], + "rightFoot": [KeypointFrame.RIGHT_ANKLE, KeypointFrame.RIGHT_FOOT_INDEX], + "rightToes": [KeypointFrame.RIGHT_ANKLE, KeypointFrame.RIGHT_FOOT_INDEX], +} + +## 处理顺序(父在前) +const DRIVE_ORDER: Array[String] = [ + "hips", "spine", "chest", "upperChest", "neck", "head", + "leftShoulder", "leftUpperArm", "leftLowerArm", "leftHand", + "rightShoulder", "rightUpperArm", "rightLowerArm", "rightHand", + "leftUpperLeg", "leftLowerLeg", "leftFoot", "leftToes", + "rightUpperLeg", "rightLowerLeg", "rightFoot", "rightToes", +] + +var skeleton: Skeleton3D = null +var bones: Dictionary = {} + +## 每个被驱动骨骼的静止数据 +var _rest_basis: Dictionary = {} # canonical → Basis(静止时的全局姿态朝向) +var _rest_dir: Dictionary = {} # canonical → Vector3(静止时的骨骼指向) +var _parent_index: Dictionary = {} # canonical → 父骨骼下标 +var _bone_index: Dictionary = {} # canonical → 骨骼下标 +var _captured: bool = false + +## 平滑系数:1.0 = 不插值,越小越平滑(用于抑制抖动) +var smoothing: float = 1.0 +var _previous_rotation: Dictionary = {} + +var last_error: String = "" + + +func _init(target_skeleton: Skeleton3D = null, matched_bones: Dictionary = {}) -> void: + skeleton = target_skeleton + bones = matched_bones + + +## 绑定骨架并采集静止姿态。切换模型时必须重新调用。 +func bind(target_skeleton: Skeleton3D, matched_bones: Dictionary) -> void: + skeleton = target_skeleton + bones = matched_bones + capture_rest() + + +## 采集静止姿态。调用前须确保所有骨骼姿态已被重置。 +func capture_rest() -> void: + _rest_basis.clear() + _rest_dir.clear() + _parent_index.clear() + _bone_index.clear() + _previous_rotation.clear() + _captured = false + + if skeleton == null: + last_error = "未绑定骨架" + return + + for canonical in DRIVE_ORDER: + if not bones.has(canonical): + continue + var idx := int(bones[canonical]) + _bone_index[canonical] = idx + _parent_index[canonical] = skeleton.get_bone_parent(idx) + _rest_basis[canonical] = skeleton.get_bone_global_pose(idx).basis + _rest_dir[canonical] = _compute_rest_direction(idx) + + _captured = true + + +## 骨骼静止时的指向:优先用首个子骨的位置差,无子骨时退回骨骼本地 Y 轴 +func _compute_rest_direction(idx: int) -> Vector3: + var origin := skeleton.get_bone_global_pose(idx).origin + for child in skeleton.get_bone_children(idx): + var child_origin := skeleton.get_bone_global_pose(child).origin + var delta := child_origin - origin + if delta.length() > 1e-6: + return delta.normalized() + var y_axis := skeleton.get_bone_global_pose(idx).basis.y + if y_axis.length() > 1e-6: + return y_axis.normalized() + return Vector3.UP + + +func is_bound() -> bool: + return skeleton != null and _captured + + +func driven_bone_count() -> int: + return _bone_index.size() + + +## 把关键点从 MediaPipe 归一化图像坐标转到 Godot 世界方向 +## 契约 skeleton-topology.md §4:x,y ∈ [0,1] 原点左上;z 负值朝相机。 +## Godot:+Y 向上,+Z 朝向相机(相机看向 -Z)。 +static func to_godot(point: Vector3, aspect: float) -> Vector3: + # x、z 以图像宽为基准,y 以图像高为基准,故 x/z 需乘宽高比 + return Vector3(point.x * aspect, -point.y, -point.z * aspect) + + +## 由姿态帧解出 35 个位置(33 点 + neck + pelvis),单位为 Godot 世界方向 +static func extract_positions(frame: KeypointFrame.PoseFrame, aspect: float = 4.0 / 3.0) -> Array: + var points := frame.unmirrored_points() + var out: Array = [] + out.resize(VIRTUAL_PELVIS + 1) + for i in KeypointFrame.KEYPOINT_COUNT: + out[i] = to_godot(points[i], aspect) + out[VIRTUAL_NECK] = (out[KeypointFrame.LEFT_SHOULDER] + out[KeypointFrame.RIGHT_SHOULDER]) * 0.5 + out[VIRTUAL_PELVIS] = (out[KeypointFrame.LEFT_HIP] + out[KeypointFrame.RIGHT_HIP]) * 0.5 + return out + + +## 应用一帧关键点;返回被驱动的骨骼数量 +func apply(frame: KeypointFrame.PoseFrame, aspect: float = 4.0 / 3.0) -> int: + if not is_bound() or frame == null: + return 0 + + var positions := extract_positions(frame, aspect) + var computed: Dictionary = {} + var applied := 0 + + for canonical in DRIVE_ORDER: + if not _bone_index.has(canonical): + continue + var idx: int = _bone_index[canonical] + var chain: Array = BONE_CHAINS[canonical] + var from_point: Vector3 = positions[chain[0]] + var to_point: Vector3 = positions[chain[1]] + var target_dir := to_point - from_point + + var rest_dir: Vector3 = _rest_dir[canonical] + if target_dir.length() < 1e-6: + continue + target_dir = target_dir.normalized() + + var delta := Quaternion(rest_dir, target_dir) + var desired: Basis = Basis(delta) * (_rest_basis[canonical] as Basis) + + # 父级全局朝向:优先用本帧已计算的值,否则取骨架当前值 + var parent_idx: int = _parent_index[canonical] + var parent_basis: Basis + if computed.has(parent_idx): + parent_basis = computed[parent_idx] + else: + parent_basis = skeleton.get_bone_global_pose(parent_idx).basis + + var local := (parent_basis.inverse() * desired).get_rotation_quaternion() + + if smoothing < 1.0 and _previous_rotation.has(canonical): + local = (_previous_rotation[canonical] as Quaternion).slerp(local, smoothing) + _previous_rotation[canonical] = local + + skeleton.set_bone_pose_rotation(idx, local) + computed[idx] = desired + applied += 1 + + return applied + + +## 清除驱动,恢复静止姿态 +func reset() -> void: + if skeleton == null: + return + for canonical in _bone_index: + skeleton.set_bone_pose_rotation(int(_bone_index[canonical]), Quaternion.IDENTITY) + _previous_rotation.clear() + + +## 平均关键点置信度(用于 UI 提示「识别质量差」) +static func average_confidence(frame: KeypointFrame.PoseFrame) -> float: + if frame == null or frame.confidence.is_empty(): + return 0.0 + var total := 0.0 + for c in frame.confidence: + total += c + return total / float(frame.confidence.size()) diff --git a/src/anim/skeleton_retarget.gd.uid b/src/anim/skeleton_retarget.gd.uid new file mode 100644 index 0000000..4c95ec5 --- /dev/null +++ b/src/anim/skeleton_retarget.gd.uid @@ -0,0 +1 @@ +uid://cd8o85svrac0i diff --git a/tests/test_retarget.gd b/tests/test_retarget.gd new file mode 100644 index 0000000..399019b --- /dev/null +++ b/tests/test_retarget.gd @@ -0,0 +1,281 @@ +extends TestCase + +## T-WIN-006:33 点 → VRM 骨骼重定向 +## +## 纯逻辑部分总是运行;真实模型部分在找不到 .vrm 时跳过。 +## 其中最关键的断言是**功能性**的:给定一组关键点, +## 重定向后骨骼在世界空间的实际指向必须与关键点方向一致。 + +const KeypointFrame = preload("res://src/net/keypoint_frame.gd") +const VrmLoader = preload("res://src/model/vrm_loader.gd") +const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd") + +const ASPECT := 4.0 / 3.0 +const CANDIDATE_PATHS := [ + "res://_scratch/sample.vrm", + "user://models/sample.vrm", +] + +var _scene: Node = null + + +func _find_model_path() -> String: + for path in CANDIDATE_PATHS: + if FileAccess.file_exists(path): + return path + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--vrm="): + var path := arg.substr(6) + if FileAccess.file_exists(path): + return path + return "" + + +func _tree() -> SceneTree: + return Engine.get_main_loop() as SceneTree + + +## 加载真实模型并挂入场景树(返回 VrmModel,失败返回 null) +func _load_model() -> VrmLoader.VrmModel: + var path := _find_model_path() + if path == "": + return null + var model: VrmLoader.VrmModel = VrmLoader.load_from_file(path) + if not model.available or model.skeleton == null: + model.dispose() + return null + _scene = model.root + _tree().root.add_child(_scene) + return model + + +func _unload_model(model: VrmLoader.VrmModel) -> void: + if _scene != null and is_instance_valid(_scene): + _tree().root.remove_child(_scene) + _scene.free() + _scene = null + if model != null: + model.dispose() + + +## 构造一帧:左臂水平向侧面伸出,其余保持站姿 +func _frame_with_left_arm_out() -> KeypointFrame.PoseFrame: + var frame := KeypointFrame.PoseFrame.new() + frame.points.resize(33) + frame.confidence.resize(33) + frame.points.fill(Vector3(0.5, 0.5, 0.0)) + frame.confidence.fill(1.0) + + frame.points[KeypointFrame.NOSE] = Vector3(0.50, 0.18, 0.0) + frame.points[KeypointFrame.LEFT_SHOULDER] = Vector3(0.60, 0.30, 0.0) + frame.points[KeypointFrame.RIGHT_SHOULDER] = Vector3(0.40, 0.30, 0.0) + frame.points[KeypointFrame.LEFT_ELBOW] = Vector3(0.72, 0.30, 0.0) + frame.points[KeypointFrame.LEFT_WRIST] = Vector3(0.84, 0.30, 0.0) + frame.points[KeypointFrame.LEFT_INDEX] = Vector3(0.88, 0.30, 0.0) + frame.points[KeypointFrame.RIGHT_ELBOW] = Vector3(0.30, 0.42, 0.0) + frame.points[KeypointFrame.RIGHT_WRIST] = Vector3(0.22, 0.55, 0.0) + frame.points[KeypointFrame.RIGHT_INDEX] = Vector3(0.20, 0.58, 0.0) + frame.points[KeypointFrame.LEFT_HIP] = Vector3(0.55, 0.62, 0.0) + frame.points[KeypointFrame.RIGHT_HIP] = Vector3(0.45, 0.62, 0.0) + frame.points[KeypointFrame.LEFT_KNEE] = Vector3(0.56, 0.78, 0.0) + frame.points[KeypointFrame.RIGHT_KNEE] = Vector3(0.44, 0.78, 0.0) + frame.points[KeypointFrame.LEFT_ANKLE] = Vector3(0.57, 0.94, 0.0) + frame.points[KeypointFrame.RIGHT_ANKLE] = Vector3(0.43, 0.94, 0.0) + frame.points[KeypointFrame.LEFT_FOOT_INDEX] = Vector3(0.57, 0.98, 0.0) + frame.points[KeypointFrame.RIGHT_FOOT_INDEX] = Vector3(0.43, 0.98, 0.0) + return frame + + +# ---------------- 纯逻辑 ---------------- + +func test_to_godot_axis_conversion() -> void: + var center := SkeletonRetarget.to_godot(Vector3(0.5, 0.5, 0.0), ASPECT) + almost(center.x, 0.5 * ASPECT, 1e-6, "x 按宽高比缩放") + almost(center.y, -0.5, 1e-6, "y 取反(屏幕向下 → 世界向下)") + + # 屏幕上方(y 小)应得到更大的世界 Y + var higher := SkeletonRetarget.to_godot(Vector3(0.5, 0.2, 0.0), ASPECT) + var lower := SkeletonRetarget.to_godot(Vector3(0.5, 0.8, 0.0), ASPECT) + ok(higher.y > lower.y, "屏幕上方映射到世界更高处") + + # MediaPipe z 负值朝相机;Godot +Z 朝相机 + var toward_camera := SkeletonRetarget.to_godot(Vector3(0.5, 0.5, -0.2), ASPECT) + ok(toward_camera.z > 0.0, "MediaPipe 负 z(朝相机)映射到 Godot +Z", "实际 %.4f" % toward_camera.z) + + +func test_extract_positions_adds_virtual_joints() -> void: + var positions := SkeletonRetarget.extract_positions(_frame_with_left_arm_out(), ASPECT) + eq(positions.size(), 35, "输出 33 点 + neck + pelvis 共 35 项") + + var expected_neck: Vector3 = (positions[KeypointFrame.LEFT_SHOULDER] + positions[KeypointFrame.RIGHT_SHOULDER]) * 0.5 + var expected_pelvis: Vector3 = (positions[KeypointFrame.LEFT_HIP] + positions[KeypointFrame.RIGHT_HIP]) * 0.5 + almost((positions[SkeletonRetarget.VIRTUAL_NECK] - expected_neck).length(), 0.0, 1e-6, "neck 为双肩中点") + almost((positions[SkeletonRetarget.VIRTUAL_PELVIS] - expected_pelvis).length(), 0.0, 1e-6, "pelvis 为双髋中点") + + +func test_mirrored_frame_is_normalized() -> void: + var frame := _frame_with_left_arm_out() + var plain := SkeletonRetarget.extract_positions(frame, ASPECT) + + frame.flags = KeypointFrame.FLAG_MIRRORED + var mirrored := SkeletonRetarget.extract_positions(frame, ASPECT) + + ok( + absf(mirrored[KeypointFrame.LEFT_WRIST].x - plain[KeypointFrame.LEFT_WRIST].x) > 1e-3, + "镜像帧的 x 被翻转" + ) + + +func test_average_confidence() -> void: + var frame := _frame_with_left_arm_out() + almost(SkeletonRetarget.average_confidence(frame), 1.0, 1e-6, "全 1 置信度均值 1.0") + frame.confidence.fill(0.5) + almost(SkeletonRetarget.average_confidence(frame), 0.5, 1e-5, "全 0.5 置信度均值 0.5") + + +func test_unbound_retarget_is_safe() -> void: + var retarget := SkeletonRetarget.new() + is_false(retarget.is_bound(), "未绑定时 is_bound 为 false") + eq(retarget.apply(_frame_with_left_arm_out()), 0, "未绑定时 apply 返回 0 且不报错") + + +func test_bone_chains_cover_all_drive_order_entries() -> void: + for canonical in SkeletonRetarget.DRIVE_ORDER: + is_true(SkeletonRetarget.BONE_CHAINS.has(canonical), "%s 有对应骨链定义" % canonical) + for canonical in SkeletonRetarget.BONE_CHAINS: + var chain: Array = SkeletonRetarget.BONE_CHAINS[canonical] + eq(chain.size(), 2, "%s 骨链为二元组" % canonical) + for point in chain: + ok(int(point) >= 0 and int(point) <= SkeletonRetarget.VIRTUAL_PELVIS, "%s 的端点索引合法" % canonical) + + +# ---------------- 真实模型:功能性验证 ---------------- + +func test_retarget_actually_drives_real_skeleton() -> void: + var model := await _load_model() + if model == null: + skip("真实模型重定向", "未找到 .vrm(放置于 _scratch/sample.vrm 或传 --vrm=<路径>)") + return + + var skeleton := model.skeleton + skeleton.reset_bone_poses() + await _tree().process_frame + + var retarget := SkeletonRetarget.new(skeleton, model.bones) + retarget.capture_rest() + is_true(retarget.is_bound(), "重定向器绑定成功") + ok(retarget.driven_bone_count() >= 16, "至少驱动 16 根骨骼", "实际 %d" % retarget.driven_bone_count()) + + var frame := _frame_with_left_arm_out() + var applied := retarget.apply(frame, ASPECT) + ok(applied >= 16, "apply 返回被驱动的骨骼数", "实际 %d" % applied) + await _tree().process_frame + + # 期望方向:关键点给出的左上臂方向 + var positions := SkeletonRetarget.extract_positions(frame, ASPECT) + var expected_upper: Vector3 = ( + positions[KeypointFrame.LEFT_ELBOW] - positions[KeypointFrame.LEFT_SHOULDER] + ).normalized() + var expected_lower: Vector3 = ( + positions[KeypointFrame.LEFT_WRIST] - positions[KeypointFrame.LEFT_ELBOW] + ).normalized() + + var upper_idx := model.bone_index("leftUpperArm") + var lower_idx := model.bone_index("leftLowerArm") + var hand_idx := model.bone_index("leftHand") + + var upper_origin: Vector3 = skeleton.get_bone_global_pose(upper_idx).origin + var lower_origin: Vector3 = skeleton.get_bone_global_pose(lower_idx).origin + var hand_origin: Vector3 = skeleton.get_bone_global_pose(hand_idx).origin + + var actual_upper: Vector3 = (lower_origin - upper_origin).normalized() + var actual_lower: Vector3 = (hand_origin - lower_origin).normalized() + + var dot_upper := actual_upper.dot(expected_upper) + var dot_lower := actual_lower.dot(expected_lower) + + print(" → 左上臂方向点积 %.4f,左前臂方向点积 %.4f" % [dot_upper, dot_lower]) + ok(dot_upper > 0.95, "左上臂实际指向与关键点方向一致", "点积 %.4f" % dot_upper) + ok(dot_lower > 0.95, "左前臂实际指向与关键点方向一致", "点积 %.4f" % dot_lower) + + # 左臂应指向角色左侧:MediaPipe 的 left_wrist.x > left_shoulder.x + ok( + positions[KeypointFrame.LEFT_WRIST].x > positions[KeypointFrame.LEFT_SHOULDER].x, + "测试用例本身左侧方向正确" + ) + + # 判别性验证:换一组关键点,骨骼必须随之改变。 + # 注意不能拿「静止姿态」做对照 —— VRM 样例本身就是 T-pose, + # 其静止左上臂方向与「手臂平举」恰好一致,那样的断言会假失败。 + var rest_basis: Basis = skeleton.get_bone_global_pose(upper_idx).basis + + var raised := _frame_with_left_arm_out() + raised.points[KeypointFrame.LEFT_ELBOW] = Vector3(0.62, 0.16, 0.0) + raised.points[KeypointFrame.LEFT_WRIST] = Vector3(0.64, 0.02, 0.0) + retarget.apply(raised, ASPECT) + await _tree().process_frame + + var raised_positions := SkeletonRetarget.extract_positions(raised, ASPECT) + var expected_raised: Vector3 = ( + raised_positions[KeypointFrame.LEFT_ELBOW] - raised_positions[KeypointFrame.LEFT_SHOULDER] + ).normalized() + var raised_upper: Vector3 = ( + skeleton.get_bone_global_pose(lower_idx).origin - skeleton.get_bone_global_pose(upper_idx).origin + ).normalized() + + ok(raised_upper.dot(expected_raised) > 0.95, "换成举手臂后,左上臂仍与关键点方向一致", "点积 %.4f" % raised_upper.dot(expected_raised)) + ok( + actual_upper.dot(raised_upper) < 0.99, + "两组不同关键点产生不同的骨骼朝向(重定向确实在起效)", + "平举与上举方向点积 %.4f" % actual_upper.dot(raised_upper) + ) + + # reset 必须回到绑定时捕获的静止朝向 + retarget.reset() + await _tree().process_frame + var after_reset: Basis = skeleton.get_bone_global_pose(upper_idx).basis + var basis_delta := 0.0 + for i in 3: + basis_delta = maxf(basis_delta, (after_reset[i] - rest_basis[i]).length()) + ok(basis_delta < 1e-4, "reset 后骨骼朝向回到静止值", "最大基向量偏差 %.6f" % basis_delta) + + await _unload_model_and_free(model) + + +func _unload_model_and_free(model: VrmLoader.VrmModel) -> void: + _unload_model(model) + + +func test_torso_rotation_propagates_to_head() -> void: + var model := await _load_model() + if model == null: + skip("躯干传播", "未找到 .vrm") + return + + var skeleton := model.skeleton + skeleton.reset_bone_poses() + await _tree().process_frame + + var retarget := SkeletonRetarget.new(skeleton, model.bones) + retarget.capture_rest() + + var head_idx := model.bone_index("head") + var hips_idx := model.bone_index("hips") + # 记录驱动前头部的相对位置 + var hips_origin: Vector3 = skeleton.get_bone_global_pose(hips_idx).origin + var head_before: Vector3 = skeleton.get_bone_global_pose(head_idx).origin - hips_origin + + # 构造一个向左侧弯腰的躯干:肩线左低右高 + var frame := _frame_with_left_arm_out() + frame.points[KeypointFrame.LEFT_SHOULDER] = Vector3(0.62, 0.40, 0.0) + frame.points[KeypointFrame.RIGHT_SHOULDER] = Vector3(0.38, 0.26, 0.0) + retarget.apply(frame, ASPECT) + await _tree().process_frame + + var head_after: Vector3 = skeleton.get_bone_global_pose(head_idx).origin - hips_origin + var moved: float = head_before.distance_to(head_after) + ok(moved > 0.005, "躯干旋转使头部位置发生位移(FK 传播生效)", "位移 %.5f" % moved) + + retarget.reset() + await _tree().process_frame + await _unload_model_and_free(model)