feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
@@ -88,6 +88,11 @@ var _current_clip: String = ""
|
||||
var _weapon_attachment: BoneAttachment3D
|
||||
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
|
||||
var _joint_helpers: Array = []
|
||||
## Contents of <model>.rig.json — resolved bone roles, cloth chains, twist pairs
|
||||
## and leg colliders, written by tools/retarget.py. Empty for a model that was
|
||||
## rebound onto the library skeleton instead of keeping its own rig.
|
||||
var _rig_info: Dictionary = {}
|
||||
var _spring_mod: SpringBones
|
||||
var is_holding_weapon: bool = false
|
||||
|
||||
# Animation blending: locomotion plays full-body through a Transition node;
|
||||
@@ -164,19 +169,44 @@ func load_model(path: String) -> void:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
# Boots weighted to BOTH legs get dragged into the gap and stretch as
|
||||
# the legs separate. See SkinLegRepair.
|
||||
var fixed := SkinLegRepair.repair(scene, skeleton)
|
||||
if fixed[0] > 0 or fixed[1] > 0:
|
||||
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
|
||||
% [path.get_file(), fixed[0], fixed[1]])
|
||||
# Half-angle helper bones at every leg joint. Without them the limb
|
||||
# pinches at a hard bend — see SkinJointHelper.
|
||||
_rig_info = _load_rig_info(path)
|
||||
# `weights_authored` is MEASURED at build time, not inferred from which
|
||||
# pipeline branch ran: a model that arrives unrigged still gets a
|
||||
# sidecar, and its nearest-bone weights still need the repair below.
|
||||
if not _rig_info.get("weights_authored", false):
|
||||
# Weights were solved, not painted — every vertex went to its
|
||||
# nearest four bones with no idea which limb it belongs to.
|
||||
# SkinLegRepair exists solely to undo that, and it is destructive
|
||||
# (it snaps weights and deletes triangles), so a model that kept its
|
||||
# ARTIST weights must never be put through it.
|
||||
var fixed := SkinLegRepair.repair(scene, skeleton)
|
||||
if fixed[0] > 0 or fixed[1] > 0:
|
||||
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
|
||||
% [path.get_file(), fixed[0], fixed[1]])
|
||||
# Joint subdivision runs for EVERY model, however it was rigged. It is
|
||||
# not a weight repair: linear-blend skinning collapses any joint by
|
||||
# cos(angle/2) no matter how good the weights are, and dropping it from
|
||||
# the authored-weight path measured 0.77 at the knee against 0.99 with
|
||||
# it. See SkinJointHelper.
|
||||
_joint_helpers = SkinJointHelper.install(scene, skeleton)
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.joint_helpers = _joint_helpers
|
||||
_pose_mod.roles = _rig_info.get("roles", {})
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
# Cloth and hair last, so the springs react to the FINAL body pose —
|
||||
# animation plus the shooter lean/slide layer.
|
||||
if not _rig_info.is_empty():
|
||||
_spring_mod = SpringBones.new()
|
||||
_spring_mod.name = "SpringBones"
|
||||
skeleton.add_child(_spring_mod)
|
||||
var driven := _spring_mod.setup(skeleton, _rig_info)
|
||||
if driven == 0:
|
||||
_spring_mod.queue_free()
|
||||
_spring_mod = null
|
||||
else:
|
||||
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
|
||||
% [path.get_file(), driven])
|
||||
|
||||
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
||||
LevelMaterials.apply_toon_recursive(scene)
|
||||
@@ -201,6 +231,23 @@ func load_model(path: String) -> void:
|
||||
_play_clip("Idle")
|
||||
|
||||
|
||||
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
|
||||
##
|
||||
## Its presence is also the signal that this model kept its OWN skeleton and
|
||||
## authored weights, which is what decides whether the load-time weight repair
|
||||
## below is needed at all.
|
||||
func _load_rig_info(model_path: String) -> Dictionary:
|
||||
var side := model_path.get_basename() + ".rig.json"
|
||||
if not FileAccess.file_exists(side):
|
||||
return {}
|
||||
var text := FileAccess.get_file_as_string(side)
|
||||
var parsed = JSON.parse_string(text)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_warning("SkinnedPlayerModel: could not parse '%s'" % side)
|
||||
return {}
|
||||
return parsed
|
||||
|
||||
|
||||
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
||||
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||
@@ -782,6 +829,10 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
## Role -> actual bone name for THIS rig, from <model>.rig.json. Empty when
|
||||
## the model was rebound onto the library skeleton, where the names below
|
||||
## already match.
|
||||
var roles: Dictionary = {}
|
||||
|
||||
func _resolve() -> void:
|
||||
var skel := get_skeleton()
|
||||
@@ -789,8 +840,29 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
|
||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
|
||||
# The names above are the LIBRARY skeleton's. A model that kept its own
|
||||
# rig names things differently and three of them simply do not exist on
|
||||
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
|
||||
# no bone with "neck" in its name at all. Unresolved, every lean, aim
|
||||
# pitch and slide head-lift below silently did nothing.
|
||||
var alias := {}
|
||||
if not roles.is_empty():
|
||||
var neck: String = roles.get("neck", "")
|
||||
var head: String = roles.get("head", "")
|
||||
var torso: Array = []
|
||||
for n in roles.get("spine", []):
|
||||
if n != neck and n != head:
|
||||
torso.append(n)
|
||||
for i in mini(torso.size(), SPINE.size() - 1):
|
||||
alias[SPINE[i + 1]] = torso[i]
|
||||
for n in names:
|
||||
_idx[n] = skel.find_bone(n)
|
||||
# Canonical names are the role keys with the DEF- prefix, so the
|
||||
# limbs, hips, neck and head all map straight through.
|
||||
var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n))
|
||||
var b := skel.find_bone(actual)
|
||||
if b < 0:
|
||||
b = skel.find_bone(n)
|
||||
_idx[n] = b
|
||||
_resolved = true
|
||||
|
||||
func _process_modification() -> void:
|
||||
|
||||
Reference in New Issue
Block a user