feat(win): T-WIN-005 VRM 加载 + humanoid 骨骼名映射
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b538smw833qym
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cv6yuyhh6k5s6
|
||||
@@ -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 版本号为空")
|
||||
Reference in New Issue
Block a user