feat(win): T-WIN-005 VRM 加载 + humanoid 骨骼名映射

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 2e539c7fa6
commit 0ae1c101a7
5 changed files with 501 additions and 0 deletions
+163
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b538smw833qym
+153
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
uid://cv6yuyhh6k5s6
+183
View File
@@ -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 版本号为空")