feat: implement automated 3D character pipeline with retargeting and rig management tools
This commit is contained in:
@@ -34,10 +34,15 @@ class_name SkinJointHelper
|
||||
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
|
||||
## with its child deforms the limb instead of saving it.
|
||||
|
||||
## [parent, child] per joint, both sides.
|
||||
const JOINTS := [
|
||||
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
|
||||
]
|
||||
## The CHILD bone of each joint to subdivide. The parent is whatever the
|
||||
## skeleton says it is, not a second hardcoded name.
|
||||
##
|
||||
## It used to be a [parent, child] pair of ["DEF-thigh.L", "DEF-shin.L"], which
|
||||
## silently did nothing on a rig with limb twist bones: Taila's shin hangs off
|
||||
## DEF-thigh.L.001, so the knee vertices are weighted across THAT and the shin,
|
||||
## and the pass found no vertex holding both named bones. The knee measured 0.76
|
||||
## with this "installed" and doing nothing at all.
|
||||
const JOINT_CHILDREN := ["DEF-shin.L", "DEF-shin.R", "shin.L", "shin.R"]
|
||||
## Angular steps through each joint. 4 leaves at most a quarter of the bend for
|
||||
## any one vertex to blend across, which is a ~3% collapse at a hard tuck.
|
||||
const SEGMENTS := 4
|
||||
@@ -55,16 +60,18 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
||||
var driven: Array = []
|
||||
# child bone -> [parent bone, [helper bones, inner first]]
|
||||
var joint_helpers := {}
|
||||
for joint in JOINTS:
|
||||
var parent := skeleton.find_bone(joint[0])
|
||||
var child := skeleton.find_bone(joint[1])
|
||||
if parent < 0 or child < 0:
|
||||
for child_name in JOINT_CHILDREN:
|
||||
var child := skeleton.find_bone(child_name)
|
||||
if child < 0:
|
||||
continue
|
||||
if skeleton.find_bone("HELPER1-" + joint[1]) >= 0:
|
||||
var parent := skeleton.get_bone_parent(child)
|
||||
if parent < 0:
|
||||
continue
|
||||
if skeleton.find_bone("HELPER1-" + child_name) >= 0:
|
||||
continue # already installed
|
||||
var helpers: Array = []
|
||||
for step in range(1, SEGMENTS):
|
||||
var hname: String = "HELPER%d-%s" % [step, joint[1]]
|
||||
var hname: String = "HELPER%d-%s" % [step, child_name]
|
||||
skeleton.add_bone(hname)
|
||||
var h := skeleton.find_bone(hname)
|
||||
skeleton.set_bone_parent(h, parent)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
extends SkeletonModifier3D
|
||||
class_name SpringBones
|
||||
|
||||
## Secondary motion for cloth, hair and accessories.
|
||||
##
|
||||
## The animation clips drive the BODY and nothing else — tools/retarget.py
|
||||
## deliberately exports no tracks at all for skirt, hair or accessory bones (see
|
||||
## its `export_optimize_animation_keep_anim_armature=False`). This is what moves
|
||||
## them, and it is the half of the pipeline that makes clothes read as clothes.
|
||||
##
|
||||
## Why this rather than skin weights. Linear-blend skinning can only ever make a
|
||||
## garment a rigid shell of whatever bones it is weighted to: weight a skirt to
|
||||
## the thighs and it becomes trousers, weight it to the hips and it becomes a
|
||||
## bell that never moves. Neither is cloth. A skirt is cloth because it LAGS —
|
||||
## it keeps going when the hips stop, swings out through a turn, and floats on
|
||||
## the way up through a jump. That is inertia, and inertia has to be integrated,
|
||||
## not skinned. So the thigh stays solid (authored weights, its own bone) while
|
||||
## the skirt hanging over it is free to move differently — which is exactly the
|
||||
## split the model was rigged for and the old pipeline flattened away.
|
||||
##
|
||||
## Each bone is a damped spring holding its tip toward where rigidly following
|
||||
## its parent would have put it:
|
||||
##
|
||||
## a = (rest_tip - tip) * w^2 - v * 2*zeta*w + gravity
|
||||
##
|
||||
## A real spring rather than the usual Verlet blend, because it is integrated
|
||||
## against the actual frame delta and so behaves the same at 30 fps and 240.
|
||||
## The tip is then pinned back to the bone's length (cloth stretches far less
|
||||
## than it swings) and pushed out of the leg capsules, so a skirt swings AROUND
|
||||
## a thigh instead of through it.
|
||||
##
|
||||
## Bone lengths and the leg capsule radii are MEASURED from the model's own
|
||||
## geometry at build time and read from <model>.rig.json — a glTF skeleton
|
||||
## carries no bone tails at all, and Taila's 21 skirt panel bones have no
|
||||
## children either, so there is nothing in the skeleton itself that says which
|
||||
## way a panel hangs or how thick a thigh is.
|
||||
|
||||
## Per class: w = stiffness as an angular frequency (rad/s), zeta = damping
|
||||
## ratio (1.0 is critical, lower overshoots), gravity in m/s^2.
|
||||
##
|
||||
## Hair is stiffer and lighter than cloth so it settles quickly instead of
|
||||
## wobbling; a skirt is slacker and heavier so it lags and swings. Gravity is
|
||||
## modest for both because the AUTHORED rest pose already has the garment
|
||||
## hanging — this only biases the droop while the body accelerates.
|
||||
const TUNING := {
|
||||
"hair": {"w": 16.0, "zeta": 0.34, "gravity": 3.0},
|
||||
"skirt": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
|
||||
"cloth": {"w": 12.0, "zeta": 0.32, "gravity": 4.5},
|
||||
"cape": {"w": 9.0, "zeta": 0.28, "gravity": 5.5},
|
||||
"coat": {"w": 11.0, "zeta": 0.30, "gravity": 5.0},
|
||||
"scarf": {"w": 12.0, "zeta": 0.30, "gravity": 4.0},
|
||||
"ribbon": {"w": 14.0, "zeta": 0.28, "gravity": 3.5},
|
||||
"tail": {"w": 13.0, "zeta": 0.30, "gravity": 3.0},
|
||||
"sleeve": {"w": 14.0, "zeta": 0.34, "gravity": 3.5},
|
||||
"breast": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
|
||||
"bust": {"w": 20.0, "zeta": 0.40, "gravity": 2.0},
|
||||
}
|
||||
const DEFAULT_TUNING := {"w": 13.0, "zeta": 0.32, "gravity": 4.0}
|
||||
|
||||
## How far a tip may stray from where rigidly following would put it, as a
|
||||
## fraction of the bone's length. Cloth swings; it does not stretch.
|
||||
const MAX_STRAY := 0.6
|
||||
## A frame delta longer than this is a hitch or a load spike. Integrating it
|
||||
## launches every chain across the map, so it is clamped instead.
|
||||
const MAX_STEP := 1.0 / 30.0
|
||||
## Origin jump (metres in one frame) that means a teleport — respawn, or the
|
||||
## model being reparented — rather than movement. Chains snap instead of whip.
|
||||
const TELEPORT := 1.5
|
||||
## Ceiling on tip speed, and on how finely one frame may be subdivided. Both are
|
||||
## backstops: nothing on a character legitimately moves this fast, and four
|
||||
## substeps already covers a 30 fps frame at the stiffest tuning here.
|
||||
const MAX_SPEED := 12.0
|
||||
const MAX_SUBSTEPS := 4
|
||||
|
||||
var _chains: Array = []
|
||||
var _colliders: Array = []
|
||||
var _tip: PackedVector3Array = PackedVector3Array()
|
||||
var _vel: PackedVector3Array = PackedVector3Array()
|
||||
var _settled: bool = false
|
||||
var _last_usec: int = 0
|
||||
|
||||
|
||||
## Build from the sidecar written by tools/retarget.py. Returns how many bones
|
||||
## are being driven, so the caller can log or disable itself when there are none.
|
||||
func setup(skel: Skeleton3D, info: Dictionary) -> int:
|
||||
_chains.clear()
|
||||
_colliders.clear()
|
||||
if skel == null or info.is_empty():
|
||||
return 0
|
||||
|
||||
var total := 0
|
||||
for entry in info.get("chains", []):
|
||||
var parent_name: String = entry.get("root_parent", "")
|
||||
var parent := skel.find_bone(parent_name) if parent_name != "" else -1
|
||||
if parent < 0:
|
||||
continue
|
||||
var bones := PackedInt32Array()
|
||||
var tips := PackedVector3Array()
|
||||
var names: Array = entry.get("bones", [])
|
||||
var raw_tips: Array = entry.get("tips", [])
|
||||
for i in names.size():
|
||||
var idx := skel.find_bone(String(names[i]))
|
||||
if idx < 0:
|
||||
continue
|
||||
var tip := Vector3.ZERO
|
||||
if i < raw_tips.size():
|
||||
var t: Array = raw_tips[i]
|
||||
if t.size() == 3:
|
||||
tip = Vector3(t[0], t[1], t[2])
|
||||
if tip.length() < 0.001:
|
||||
continue # no measurable extent — nothing to swing
|
||||
bones.append(idx)
|
||||
tips.append(tip)
|
||||
if bones.is_empty():
|
||||
continue
|
||||
var tune: Dictionary = TUNING.get(String(entry.get("class", "")), DEFAULT_TUNING)
|
||||
_chains.append({
|
||||
"parent": parent,
|
||||
"bones": bones,
|
||||
"tips": tips,
|
||||
"w": float(tune["w"]),
|
||||
"zeta": float(tune["zeta"]),
|
||||
"gravity": float(tune["gravity"]),
|
||||
})
|
||||
total += bones.size()
|
||||
|
||||
for c in info.get("colliders", []):
|
||||
var a := skel.find_bone(String(c.get("bone", "")))
|
||||
var b := skel.find_bone(String(c.get("child", "")))
|
||||
if a >= 0 and b >= 0:
|
||||
_colliders.append({"a": a, "b": b, "r": float(c.get("radius", 0.1))})
|
||||
|
||||
_tip.resize(total)
|
||||
_vel.resize(total)
|
||||
_settled = false
|
||||
_last_usec = 0
|
||||
return total
|
||||
|
||||
|
||||
func _delta() -> float:
|
||||
var now := Time.get_ticks_usec()
|
||||
if _last_usec == 0:
|
||||
_last_usec = now
|
||||
return 1.0 / 60.0
|
||||
var dt := float(now - _last_usec) / 1000000.0
|
||||
_last_usec = now
|
||||
return clampf(dt, 1.0 / 480.0, MAX_STEP)
|
||||
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if skel == null or _chains.is_empty():
|
||||
return
|
||||
var dt := _delta()
|
||||
var to_world := skel.global_transform
|
||||
var k := 0
|
||||
|
||||
for chain in _chains:
|
||||
var bones: PackedInt32Array = chain["bones"]
|
||||
var tips: PackedVector3Array = chain["tips"]
|
||||
var w: float = chain["w"]
|
||||
var damp: float = 2.0 * float(chain["zeta"]) * w
|
||||
var pull: float = w * w
|
||||
var gravity := Vector3.DOWN * float(chain["gravity"])
|
||||
# World transform of the bone this chain hangs from, already posed by
|
||||
# the animation and the shooter pose layer this frame.
|
||||
var parent_world: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"])
|
||||
|
||||
for i in bones.size():
|
||||
var bone: int = bones[i]
|
||||
# Where rigidly following the parent would put this bone. Built from
|
||||
# the REST offset, not the current pose, or last frame's spring
|
||||
# result would compound into a permanent drift.
|
||||
var rigid: Transform3D = parent_world * skel.get_bone_rest(bone)
|
||||
var origin := rigid.origin
|
||||
var rest_tip: Vector3 = rigid * tips[i]
|
||||
var arm := rest_tip - origin
|
||||
var length := arm.length()
|
||||
if length < 0.0001:
|
||||
k += 1
|
||||
continue
|
||||
|
||||
var tip := _tip[k]
|
||||
var vel := _vel[k]
|
||||
if not _settled or tip.distance_to(rest_tip) > TELEPORT:
|
||||
tip = rest_tip
|
||||
vel = Vector3.ZERO
|
||||
|
||||
# Substep so the spring can never overshoot, however stiff it is or
|
||||
# however long the frame was. A single explicit step is only stable
|
||||
# while w*dt stays small; past that it gains energy every frame and
|
||||
# the chain flies off the model.
|
||||
var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS)
|
||||
var h := dt / float(sub)
|
||||
for _s in sub:
|
||||
vel += ((rest_tip - tip) * pull - vel * damp + gravity) * h
|
||||
tip += vel * h
|
||||
# Cloth swings but barely stretches: hold the tip on the bone's
|
||||
# own sphere.
|
||||
var offset := tip - origin
|
||||
tip = origin + (offset if offset.length() > 0.0001 else arm).normalized() * length
|
||||
# Velocity is taken OUT of the constraint rather than recovered
|
||||
# from a finite difference afterwards. Dividing a projected
|
||||
# position change by a wall-clock delta is what produced the
|
||||
# 1.46 m fling: a short frame turns a millimetre of correction
|
||||
# into metres per second, and the next frame launches the chain.
|
||||
var radial := (tip - origin) / length
|
||||
vel -= radial * vel.dot(radial)
|
||||
vel = vel.limit_length(MAX_SPEED)
|
||||
|
||||
# Cap how far the tip may stray from the rigid pose, so a hard turn
|
||||
# cannot fold a chain back through the body.
|
||||
var stray := tip - rest_tip
|
||||
var limit := length * MAX_STRAY
|
||||
if stray.length() > limit:
|
||||
tip = origin + ((rest_tip + stray.normalized() * limit) - origin).normalized() * length
|
||||
|
||||
tip = _push_out_of_legs(skel, to_world, tip, origin, length)
|
||||
_vel[k] = vel
|
||||
_tip[k] = tip
|
||||
|
||||
# Turn the tip direction back into this bone's local rotation.
|
||||
var swing := Quaternion(arm.normalized(), (tip - origin).normalized())
|
||||
var world := Transform3D(Basis(swing) * rigid.basis, origin)
|
||||
var local := parent_world.affine_inverse() * world
|
||||
skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion())
|
||||
parent_world = world
|
||||
k += 1
|
||||
|
||||
_settled = true
|
||||
|
||||
|
||||
## Keep a tip outside the leg capsules, so a skirt swings AROUND a thigh rather
|
||||
## than through it. Radii are measured from the model's own body geometry at
|
||||
## build time (tools/retarget.py::_leg_colliders).
|
||||
func _push_out_of_legs(skel: Skeleton3D, to_world: Transform3D, tip: Vector3,
|
||||
origin: Vector3, length: float) -> Vector3:
|
||||
for col in _colliders:
|
||||
var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin
|
||||
var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin
|
||||
var ab := b - a
|
||||
var d2 := ab.length_squared()
|
||||
var t := 0.0 if d2 < 0.000001 else clampf((tip - a).dot(ab) / d2, 0.0, 1.0)
|
||||
var closest := a + ab * t
|
||||
var away := tip - closest
|
||||
var dist := away.length()
|
||||
var r: float = col["r"]
|
||||
if dist >= r:
|
||||
continue
|
||||
if dist < 0.0001:
|
||||
away = (tip - origin).cross(ab)
|
||||
if away.length() < 0.0001:
|
||||
continue
|
||||
tip = closest + away.normalized() * r
|
||||
# Pushing off a capsule moves the tip off its own sphere; put it back.
|
||||
var offset := tip - origin
|
||||
if offset.length() > 0.0001:
|
||||
tip = origin + offset.normalized() * length
|
||||
return tip
|
||||
@@ -0,0 +1 @@
|
||||
uid://deskxxpcexwyt
|
||||
Reference in New Issue
Block a user