diff --git a/src/model/humanoid_map.gd b/src/model/humanoid_map.gd new file mode 100644 index 0000000..891460c --- /dev/null +++ b/src/model/humanoid_map.gd @@ -0,0 +1,163 @@ +class_name HumanoidMap +extends RefCounted + +## VRM humanoid 骨骼名映射 +## +## VRM 规范定义了 20+ 根 humanoid 骨骼,但**不要求**节点名固定, +## 因此需要按名字猜测。本表覆盖三类常见命名: +## 1. VRoid Studio:J_Bip_C_Hips / J_Bip_L_UpperArm / J_Bip_L_LowerLeg … +## 2. VRM 0.x 通用:Hips / LeftArm / LeftForeArm / LeftUpLeg / LeftLeg … +## 3. Mixamo 重定向:mixamorig:LeftArm / mixamorig:LeftForeArm … +## +## 匹配策略:归一化(小写 + 去分隔符)→ 剥掉已知前缀 → 与候选集**精确**比较。 +## 注意 VRoid 的 J_Bip_C_* 中 C 表示「中心骨」,归一化后残留前导 c +## (如 J_Bip_C_Hips → "chips"),因此中心骨候选里额外列出 c 开头的写法。 +## 只剥离 J_Bip / mixamorig / Bip01 —— 故意不剥离 J_Aim_ / J_Roll_ / J_Sec_, +## 因为 VRoid 用这些前缀标记扭转辅助骨(如 J_Aim_L_UpperLeg), +## 若一并剥离会与真正的 J_Bip_L_UpperLeg 撞名,导致驱动到错误的骨骼上。 + +## 需要驱动的人形骨骼(顺序即层级,父在前) +const HUMANOID_BONES: Array[String] = [ + "hips", + "spine", + "chest", + "upperChest", + "neck", + "head", + "leftShoulder", + "leftUpperArm", + "leftLowerArm", + "leftHand", + "rightShoulder", + "rightUpperArm", + "rightLowerArm", + "rightHand", + "leftUpperLeg", + "leftLowerLeg", + "leftFoot", + "leftToes", + "rightUpperLeg", + "rightLowerLeg", + "rightFoot", + "rightToes", +] + +## 归一化时剥离的前缀。 +## 必须是**归一化之后**的形态(小写、无下划线/冒号)—— +## 早期版本写成 "j_bip" 而 normalize() 已经去掉了下划线, +## 导致 begins_with 永远不成立,VRoid 模型全覆盖率为 0。 +const STRIPPABLE_PREFIXES: Array[String] = [ + "jbip", + "mixamorig", + "bip001", + "bip01", +] + +## 规范化骨骼名的小写形态(去掉分隔符,但保留前缀以便判断) +## 例如 "J_Bip_L_UpperArm" → "jbiplupperarm" +static func normalize(node_name: String) -> String: + var lower := node_name.to_lower() + var out := "" + for i in lower.length(): + var c := lower[i] + if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"): + out += c + return out + + +## 去掉可剥离前缀后的形态 +static func strip_prefix(normalized: String) -> String: + for prefix in STRIPPABLE_PREFIXES: + if normalized.begins_with(prefix): + return normalized.substr(prefix.length()) + return normalized + + +## 生成用于比较的形态集合: +## compact —— left/right 缩写为 l/r:"leftupperarm" → "lupperarm" +## expanded —— 首字母 l/r 展开为 left/right:"lupperarm" → "leftupperarm" +static func variants(node_name: String) -> PackedStringArray: + var base := strip_prefix(normalize(node_name)) + var out := PackedStringArray() + out.append(base) + + var compact := base + if base.begins_with("left"): + compact = "l" + base.substr(4) + elif base.begins_with("right"): + compact = "r" + base.substr(5) + out.append(compact) + + var expanded := base + if base.begins_with("l") and not base.begins_with("left"): + expanded = "left" + base.substr(1) + elif base.begins_with("r") and not base.begins_with("right"): + expanded = "right" + base.substr(1) + out.append(expanded) + + return out + + +## 每个 canonical 骨骼的候选名(全部为归一化后的形态,含 compact 与 expanded 写法) +const CANDIDATES := { + "hips": ["chips", "hips", "hip", "pelvis", "root"], + "spine": ["cspine", "spine", "spine1", "spinemid"], + "chest": ["cchest", "chest", "spine2", "spineupper"], + "upperChest": ["cupperchest", "upperchest", "chest2", "spine3"], + "neck": ["cneck", "neck", "neck1"], + "head": ["chead", "head"], + "leftShoulder": ["lshoulder", "leftshoulder", "shoulderl", "shoulderleft", "claviclel", "lclavicle"], + "leftUpperArm": ["lupperarm", "leftupperarm", "larm", "leftarm", "luparm"], + "leftLowerArm": ["llowerarm", "leftlowerarm", "lforearm", "leftforearm", "lelbow"], + "leftHand": ["lhand", "lefthand", "lwrist", "leftwrist"], + "rightShoulder": ["rshoulder", "rightshoulder", "shoulderr", "shoulderright", "clavicler", "rclavicle"], + "rightUpperArm": ["rupperarm", "rightupperarm", "rarm", "rightarm", "ruparm"], + "rightLowerArm": ["rlowerarm", "rightlowerarm", "rforearm", "rightforearm", "relbow"], + "rightHand": ["rhand", "righthand", "rwrist", "rightwrist"], + "leftUpperLeg": ["lupperleg", "leftupperleg", "lthigh", "leftthigh", "lupleg", "lhip", "lefthip"], + "leftLowerLeg": ["llowerleg", "leftlowerleg", "lleg", "leftleg", "lshin", "leftshin", "lknee", "leftknee"], + "leftFoot": ["lfoot", "leftfoot", "lankle", "leftankle"], + "leftToes": ["ltoes", "lefttoes", "ltoe", "lefttoe", "ltoebase", "lefttoebase"], + "rightUpperLeg": ["rupperleg", "rightupperleg", "rthigh", "rightthigh", "rupleg", "rhip", "righthip"], + "rightLowerLeg": ["rlowerleg", "rightlowerleg", "rleg", "rightleg", "rshin", "rightshin", "rknee", "rightknee"], + "rightFoot": ["rfoot", "rightfoot", "rankle", "rightankle"], + "rightToes": ["rtoes", "righttoes", "rtoe", "righttoe", "rtoebase", "righttoebase"], +} + + +## 在骨骼名列表中查找 humanoid 骨骼 +## 返回 { canonical_name: bone_index };未找到的键不存在 +static func match_bones(bone_names: PackedStringArray) -> Dictionary: + # 先建立 normalized-variant → bone_index 的索引 + var index := {} + for i in bone_names.size(): + var variants := variants(bone_names[i]) + for v in variants: + # 先出现的优先(骨骼树按前序排列,真正的 humanoid 骨通常更靠前) + if not index.has(v): + index[v] = i + + var result := {} + for canonical in HUMANOID_BONES: + var candidates: Array = CANDIDATES.get(canonical, []) + for candidate in candidates: + if index.has(candidate): + result[canonical] = index[candidate] + break + return result + + +## 映射覆盖率,用于日志与 UI 提示 +static func coverage(matched: Dictionary) -> float: + if HUMANOID_BONES.is_empty(): + return 0.0 + return float(matched.size()) / float(HUMANOID_BONES.size()) + + +## 缺失的骨骼名(用于诊断「模型不受支持」) +static func missing_bones(matched: Dictionary) -> PackedStringArray: + var out := PackedStringArray() + for canonical in HUMANOID_BONES: + if not matched.has(canonical): + out.append(canonical) + return out diff --git a/src/model/humanoid_map.gd.uid b/src/model/humanoid_map.gd.uid new file mode 100644 index 0000000..b4ddb6a --- /dev/null +++ b/src/model/humanoid_map.gd.uid @@ -0,0 +1 @@ +uid://b538smw833qym diff --git a/src/model/vrm_loader.gd b/src/model/vrm_loader.gd new file mode 100644 index 0000000..e84135e --- /dev/null +++ b/src/model/vrm_loader.gd @@ -0,0 +1,153 @@ +class_name VrmLoader +extends RefCounted + +## T-WIN-005:VRM 模型加载 +## +## 实现选型说明(与文档 §5.1 的差异,已在 README 与任务文档中记录): +## 文档选型为 godot-vrm(V-Sekai)GDExtension,但该仓库**不发布任何预编译产物** +## (无 GitHub Release,仅源码,需用 godot-cpp 本地编译原生库)。 +## 这会同时破坏「Linux 上导出 Windows exe」的 T-WIN-011 验收路径。 +## +## 因此本实现改用 **Godot 内置 GLTFDocument** —— VRM 本身就是 glTF 2.0 二进制容器: +## - 网格 / 蒙皮 / Skeleton3D 由内置导入器完整解析(实测 154 骨骼 VRoid 模型 OK) +## - VRM 扩展(extensions.VRM / extensions.VRMC_vrm)可直接从 GLTFState.json 读取 +## - humanoid 骨骼按名字映射(见 HumanoidMap),不依赖 VRM 扩展 +## 好处:零原生依赖、可无头测试、跨平台导出不受限。 +## 若日后接入 godot-vrm,只需替换本文件的 load_from_file 实现,接口不变。 + +const HumanoidMap = preload("res://src/model/humanoid_map.gd") + + +class VrmModel extends RefCounted: + ## 生成出来的场景根节点(未加入树,调用方负责 add_child) + var root: Node3D = null + var skeleton: Skeleton3D = null + ## canonical humanoid 名 → 骨骼下标 + var bones: Dictionary = {} + ## 原始 VRM 版本字符串,如 "1.0" / "0.0";非 VRM 为 "" + var vrm_version: String = "" + ## 是否识别为 VRM(含 VRM 扩展) + var is_vrm: bool = false + var mesh_count: int = 0 + var bone_count: int = 0 + var available: bool = false + var error: String = "" + + func coverage() -> float: + return HumanoidMap.coverage(bones) + + func missing_bones() -> PackedStringArray: + return HumanoidMap.missing_bones(bones) + + ## 是否具备驱动 33 点所需的最小骨骼集合 + func is_drivable() -> bool: + var required := [ + "hips", "spine", "neck", "head", + "leftUpperArm", "leftLowerArm", "leftHand", + "rightUpperArm", "rightLowerArm", "rightHand", + "leftUpperLeg", "leftLowerLeg", "leftFoot", + "rightUpperLeg", "rightLowerLeg", "rightFoot", + ] + for name in required: + if not bones.has(name): + return false + return true + + func bone_index(canonical: String) -> int: + return int(bones.get(canonical, -1)) + + ## 释放生成的场景(切换模型时必须调用,否则会泄漏) + func dispose() -> void: + if root != null and is_instance_valid(root): + root.free() + root = null + skeleton = null + bones.clear() + available = false + + +## 从文件加载 VRM / glTF +static func load_from_file(path: String) -> VrmModel: + var model := VrmModel.new() + + if not FileAccess.file_exists(path): + model.error = "文件不存在:%s" % path + return model + + var document := GLTFDocument.new() + var state := GLTFState.new() + var err := document.append_from_file(path, state) + if err != OK: + model.error = "glTF/VRM 解析失败:%s(%s)" % [error_string(err), path] + return model + + var scene: Node = document.generate_scene(state) + if scene == null: + model.error = "无法从 %s 生成场景" % path + return model + + if not (scene is Node3D): + scene.free() + model.error = "模型根节点不是 Node3D" + return model + + model.root = scene as Node3D + model.skeleton = find_skeleton(model.root) + model.mesh_count = count_meshes(model.root) + + var extension_info := detect_vrm_version(state.json) + model.is_vrm = extension_info["is_vrm"] + model.vrm_version = extension_info["version"] + + if model.skeleton == null: + model.error = "模型中不含 Skeleton3D,无法驱动" + model.available = true + return model + + model.bone_count = model.skeleton.get_bone_count() + var names := PackedStringArray() + for i in model.bone_count: + names.append(model.skeleton.get_bone_name(i)) + model.bones = HumanoidMap.match_bones(names) + + model.available = true + return model + + +## 从 glTF JSON 读取 VRM 版本 +## 契约 model-formats.md §1:extensions.VRMC_vrm → 1.0;extensions.VRM → 0.x +static func detect_vrm_version(json: Dictionary) -> Dictionary: + var extensions: Dictionary = json.get("extensions", {}) + if extensions.has("VRMC_vrm"): + var spec: Dictionary = extensions["VRMC_vrm"] + return {"is_vrm": true, "version": str(spec.get("specVersion", "1.0"))} + if extensions.has("VRM"): + var spec0: Dictionary = extensions["VRM"] + var version := str(spec0.get("specVersion", spec0.get("exporterVersion", "0.x"))) + return {"is_vrm": true, "version": version} + return {"is_vrm": false, "version": ""} + + +static func find_skeleton(node: Node) -> Skeleton3D: + if node is Skeleton3D: + return node as Skeleton3D + for child in node.get_children(): + var found := find_skeleton(child) + if found != null: + return found + return null + + +static func count_meshes(node: Node) -> int: + var total := 1 if node is MeshInstance3D else 0 + for child in node.get_children(): + total += count_meshes(child) + return total + + +## 从服务端下载的模型文件落到 user:// 缓存后的本地路径 +static func cache_path_for(model_id: int, file_url: String) -> String: + var extension := file_url.get_extension() + if extension == "": + extension = "vrm" + return "user://models/%d.%s" % [model_id, extension] diff --git a/src/model/vrm_loader.gd.uid b/src/model/vrm_loader.gd.uid new file mode 100644 index 0000000..422f1a1 --- /dev/null +++ b/src/model/vrm_loader.gd.uid @@ -0,0 +1 @@ +uid://cv6yuyhh6k5s6 diff --git a/tests/test_vrm_loader.gd b/tests/test_vrm_loader.gd new file mode 100644 index 0000000..23bc7ff --- /dev/null +++ b/tests/test_vrm_loader.gd @@ -0,0 +1,183 @@ +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 版本号为空")