Files
opendance-windows/tests/test_vrm_loader.gd
T

184 lines
8.2 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends TestCase
## T-WIN-005:VRM 加载与 humanoid 骨骼映射
##
## 分两部分:
## 1. 纯逻辑:HumanoidMap 对三类命名规范的识别(总是运行)
## 2. 真实模型:加载真实 .vrm 并检查绑定结果(找不到模型文件时跳过)
##
## 真实模型不入库(许可证原因),由开发者放在 _scratch/sample.vrm,
## 或通过 --vrm=<路径> 命令行参数指定。
const HumanoidMap = preload("res://src/model/humanoid_map.gd")
const VrmLoader = preload("res://src/model/vrm_loader.gd")
const CANDIDATE_PATHS := [
"res://_scratch/sample.vrm",
"user://models/sample.vrm",
]
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 test_vroid_naming_is_recognized() -> void:
var names := PackedStringArray([
"J_Bip_C_Hips", "J_Bip_C_Spine", "J_Bip_C_Chest", "J_Bip_C_UpperChest",
"J_Bip_C_Neck", "J_Bip_C_Head",
"J_Bip_L_Shoulder", "J_Bip_L_UpperArm", "J_Bip_L_LowerArm", "J_Bip_L_Hand",
"J_Bip_R_Shoulder", "J_Bip_R_UpperArm", "J_Bip_R_LowerArm", "J_Bip_R_Hand",
"J_Bip_L_UpperLeg", "J_Bip_L_LowerLeg", "J_Bip_L_Foot", "J_Bip_L_ToeBase",
"J_Bip_R_UpperLeg", "J_Bip_R_LowerLeg", "J_Bip_R_Foot", "J_Bip_R_ToeBase",
])
var matched := HumanoidMap.match_bones(names)
eq(matched.size(), HumanoidMap.HUMANOID_BONES.size(), "VRoid 命名全部 22 根骨骼均被识别")
eq(int(matched.get("hips", -1)), 0, "hips 命中 J_Bip_C_Hips")
eq(int(matched.get("leftUpperArm", -1)), 7, "leftUpperArm 命中 J_Bip_L_UpperArm")
eq(int(matched.get("leftLowerLeg", -1)), 15, "leftLowerLeg 命中 J_Bip_L_LowerLeg")
eq(int(matched.get("rightToes", -1)), 21, "rightToes 命中 J_Bip_R_ToeBase")
almost(HumanoidMap.coverage(matched), 1.0, 1e-6, "覆盖率 100%")
func test_vrm_0x_generic_naming_is_recognized() -> void:
var names := PackedStringArray([
"Hips", "Spine", "Chest", "Neck", "Head",
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
])
var matched := HumanoidMap.match_bones(names)
eq(int(matched.get("hips", -1)), 0, "hips 命中 Hips")
eq(int(matched.get("leftUpperArm", -1)), 6, "leftUpperArm 命中 LeftArm")
eq(int(matched.get("leftLowerArm", -1)), 7, "leftLowerArm 命中 LeftForeArm")
eq(int(matched.get("leftUpperLeg", -1)), 13, "leftUpperLeg 命中 LeftUpLeg")
eq(int(matched.get("leftLowerLeg", -1)), 14, "leftLowerLeg 命中 LeftLeg")
eq(int(matched.get("leftToes", -1)), 16, "leftToes 命中 LeftToeBase")
func test_mixamo_naming_is_recognized() -> void:
var names := PackedStringArray([
"mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Neck", "mixamorig:Head",
"mixamorig:LeftShoulder", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
"mixamorig:RightShoulder", "mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot", "mixamorig:LeftToeBase",
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot", "mixamorig:RightToeBase",
])
var matched := HumanoidMap.match_bones(names)
eq(int(matched.get("leftUpperArm", -1)), 6, "Mixamo 前缀被剥离,leftUpperArm 命中 LeftArm")
eq(int(matched.get("rightUpperLeg", -1)), 17, "rightUpperLeg 命中 RightUpLeg")
func test_vroid_helper_bones_do_not_shadow_real_bones() -> void:
# VRoid 的扭转辅助骨:J_Aim_L_UpperLeg 等。若错误地剥离 j_aim 前缀,
# 它会与真正的 J_Bip_L_UpperLeg 撞名并把动作驱动到错误的骨骼上。
var names := PackedStringArray([
"J_Bip_C_Hips",
"J_Aim_L_UpperLeg", "J_Roll_L_UpperLeg", "J_Sec_L_TopsUpperLegBack",
"J_Bip_L_UpperLeg",
"J_Aim_R_UpperLeg", "J_Bip_R_UpperLeg",
])
var matched := HumanoidMap.match_bones(names)
eq(int(matched.get("leftUpperLeg", -1)), 4, "leftUpperLeg 命中真正的 J_Bip_L_UpperLeg,而非 J_Aim/J_Roll")
eq(int(matched.get("rightUpperLeg", -1)), 6, "rightUpperLeg 命中 J_Bip_R_UpperLeg")
func test_unmapped_bones_are_reported() -> void:
var names := PackedStringArray(["Hips", "Neck"])
var matched := HumanoidMap.match_bones(names)
var missing := HumanoidMap.missing_bones(matched)
ok(missing.size() > 0, "缺失骨骼被列出", "缺失 %d 根" % missing.size())
ok(missing.has("leftHand"), "leftHand 被列为缺失")
is_false(missing.has("hips"), "已命中的 hips 不出现在缺失列表")
func test_coverage_reflects_match_ratio() -> void:
var full := HumanoidMap.match_bones(PackedStringArray(HumanoidMap.HUMANOID_BONES))
var partial := HumanoidMap.match_bones(PackedStringArray(["Hips", "Neck", "Head"]))
almost(HumanoidMap.coverage(full), 1.0, 1e-6, "全命中覆盖率 1.0")
ok(HumanoidMap.coverage(partial) < 1.0, "部分命中覆盖率小于 1")
ok(HumanoidMap.coverage(partial) > 0.0, "部分命中覆盖率大于 0")
# ---------------- 真实模型 ----------------
func test_real_vrm_loads_with_skeleton_and_bones() -> void:
var path := _find_model_path()
if path == "":
skip("真实 VRM 加载", "未找到模型文件(放置于 _scratch/sample.vrm 或传 --vrm=<路径>)")
return
var model: VrmLoader.VrmModel = VrmLoader.load_from_file(path)
not_null(model, "加载返回结果")
if not model.available:
ok(false, "模型加载失败", model.error)
return
is_true(model.available, "模型可用")
eq(model.error, "", "无错误信息")
not_null(model.root, "生成场景根节点")
not_null(model.skeleton, "找到 Skeleton3D")
ok(model.bone_count > 20, "骨骼数量合理", "实际 %d" % model.bone_count)
ok(model.mesh_count > 0, "含网格", "实际 %d" % model.mesh_count)
is_true(model.is_vrm, "识别为 VRM(含 VRM 扩展)")
ne(model.vrm_version, "", "读到 VRM 版本")
print(" → 模型:%s,VRM 版本 %s,%d 骨骼 / %d 网格,humanoid 覆盖率 %.0f%%" % [
path, model.vrm_version, model.bone_count, model.mesh_count, model.coverage() * 100.0,
])
ok(model.coverage() >= 0.8, "humanoid 骨骼覆盖率 ≥ 80%", "实际 %.0f%%,缺失:%s" % [
model.coverage() * 100.0, ", ".join(model.missing_bones()),
])
is_true(model.is_drivable(), "具备驱动所需的最小骨骼集合", "缺失:%s" % ", ".join(model.missing_bones()))
# 抽查若干关键骨骼确实指向名字匹配的骨骼
var checks := {
"hips": "Hips",
"leftHand": "Hand",
"rightUpperLeg": "UpperLeg",
}
for canonical in checks:
var index := model.bone_index(canonical)
ok(index >= 0, "%s 已映射" % canonical)
if index >= 0:
var bone_name := model.skeleton.get_bone_name(index)
contains(bone_name.to_lower(), str(checks[canonical]).to_lower(), "%s 映射到 %s" % [canonical, bone_name])
# 辅助骨不得被选用
is_false(bone_name.begins_with("J_Aim"), "%s 未误映射到 J_Aim 辅助骨(实际 %s)" % [canonical, bone_name])
is_false(bone_name.begins_with("J_Roll"), "%s 未误映射到 J_Roll 辅助骨(实际 %s)" % [canonical, bone_name])
model.dispose()
is_null(model.root, "dispose 后释放场景")
func test_missing_file_reports_clear_error() -> void:
var model: VrmLoader.VrmModel = VrmLoader.load_from_file("res://_scratch/definitely-not-here.vrm")
is_false(model.available, "不存在的文件标记为不可用")
contains(model.error, "不存在", "错误信息说明文件不存在")
func test_vrm_version_detection_from_json() -> void:
var v1 := VrmLoader.detect_vrm_version({"extensions": {"VRMC_vrm": {"specVersion": "1.0"}}})
is_true(bool(v1["is_vrm"]), "VRMC_vrm 识别为 VRM")
eq(str(v1["version"]), "1.0", "VRM 1.0 版本号正确")
var v0 := VrmLoader.detect_vrm_version({"extensions": {"VRM": {"specVersion": "0.0", "exporterVersion": "0.79"}}})
is_true(bool(v0["is_vrm"]), "VRM 识别为 VRM")
eq(str(v0["version"]), "0.0", "VRM 0.x 版本号正确")
var plain := VrmLoader.detect_vrm_version({"extensions": {}})
is_false(bool(plain["is_vrm"]), "无 VRM 扩展的普通 glTF 不识别为 VRM")
eq(str(plain["version"]), "", "普通 glTF 版本号为空")