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