fix: unlink the ankle cuffs, stop the reload head-dive, soften every blend
Three animation complaints, measured before changing anything. There is NO squash-and-stretch. Instrumented every bone every frame: the worst deviation of a pose basis from a pure rotation is 0.00000. Every clip's position tracks match the skeleton's rest pose to within a millimetre (only DEF-hips translates, as it should) and no scale key deviates from 1. The "squishing" is a deep BEND, not a scale: the shared library's PistolReload was authored for someone watching their own hands, and it dives the head 33° with the whole neck chain at 67°. At that depth Taila's head/hair weights pinch and the skull changes shape. Dropping neck+head from the upper-body one-shot's filter leaves them on the locomotion layer: head dive is now 14° and the neck chain 48°, and the character keeps looking downrange through a reload, which is what a shooter wants anyway. Trade-off: Throw/Hit no longer swing the head. The ankle cuffs are not linked by the rig or the clips — the foot bones swing independently (left/right Z correlation -0.94). They are linked by SKIN WEIGHTS: 16 vertices belonging to the LEFT cuff, sitting at x = -0.002 just across the centre line, are weighted to the RIGHT leg, so the far foot drags a band of cuff across the gap. Position alone cannot classify them (the cuff they belong to spans x = 0.00 .. 0.05), so SkinWeightRepair decides by CONNECTIVITY: a vertex that disagrees with 80%+ of the vertices it shares triangles with gets its leg influences mirrored. Ankle-height cross-leg triangles go 24 -> 0 on Taila; the pass is conservative enough that Miku needs exactly 1 vertex and her face/hair/skirt are untouched (only leg bones are ever considered). Meshes with blend shapes are skipped rather than rebuilt. Blending: nothing cuts hard any more. Base cross-fade 0.15 -> 0.22, locomotion 0.28-0.30 (Idle/Walk/Run/Sprint switch constantly as speed drifts across their thresholds, which is where the snapping showed most), reaction moves raised off their near-instant values but still snappy, and the upper-body one-shot's own fades widened to 0.14 in / 0.22 out. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d8b0c08611
commit
35ada4f34a
@@ -0,0 +1,186 @@
|
|||||||
|
extends Object
|
||||||
|
class_name SkinWeightRepair
|
||||||
|
|
||||||
|
## Fixes stray cross-body leg weights in imported character skins.
|
||||||
|
##
|
||||||
|
## Taila's left ankle cuff has ~24 triangles whose near-midline vertices are
|
||||||
|
## weighted to the RIGHT leg. Nothing in the rig or the clips is wrong — the
|
||||||
|
## foot bones swing independently (measured: left/right swing correlation
|
||||||
|
## -0.94) and no bone is ever scaled. But when the legs separate, those few
|
||||||
|
## vertices are dragged toward the far foot and stretch a band of cuff across
|
||||||
|
## the gap, which reads as "the ankle cuffs are linked together".
|
||||||
|
##
|
||||||
|
## Position alone cannot decide the correct side: the offending vertices sit at
|
||||||
|
## x = -0.002, barely across the centre line, while the cuff they belong to
|
||||||
|
## spans x = 0.00 .. 0.05. So we use CONNECTIVITY instead — a vertex that
|
||||||
|
## disagrees with a large majority of the vertices it shares triangles with is
|
||||||
|
## mis-weighted, and its leg influences get mirrored to the other side.
|
||||||
|
|
||||||
|
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
|
||||||
|
## A vertex flips only when this share of its neighbours disagree with it, so a
|
||||||
|
## genuine seam (where both sides legitimately meet) is left alone.
|
||||||
|
const MAJORITY := 0.8
|
||||||
|
|
||||||
|
|
||||||
|
## Repair every skinned surface under `root`. Returns the number of vertices
|
||||||
|
## re-weighted, so callers can log whether a skin needed it.
|
||||||
|
static func repair(root: Node, skeleton: Skeleton3D) -> int:
|
||||||
|
var fixed_total := 0
|
||||||
|
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh == null or mi.skin == null:
|
||||||
|
continue
|
||||||
|
# Rebuilding a mesh drops blend shapes; skins that use them (face rigs)
|
||||||
|
# are left untouched rather than silently losing expressions.
|
||||||
|
if mi.mesh.get_blend_shape_count() > 0:
|
||||||
|
continue
|
||||||
|
var fixed := _repair_mesh(mi, skeleton)
|
||||||
|
fixed_total += fixed
|
||||||
|
return fixed_total
|
||||||
|
|
||||||
|
|
||||||
|
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D) -> int:
|
||||||
|
var skin: Skin = mi.skin
|
||||||
|
var mirror := _mirror_map(skin, skeleton)
|
||||||
|
if mirror.is_empty():
|
||||||
|
return 0
|
||||||
|
var side := _side_map(skin, skeleton)
|
||||||
|
|
||||||
|
var surfaces: Array = []
|
||||||
|
var fixed_total := 0
|
||||||
|
for s in range(mi.mesh.get_surface_count()):
|
||||||
|
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||||
|
var fixed := _repair_surface(arrays, side, mirror)
|
||||||
|
fixed_total += fixed
|
||||||
|
surfaces.append({
|
||||||
|
"arrays": arrays,
|
||||||
|
"material": mi.mesh.surface_get_material(s),
|
||||||
|
"name": mi.mesh.surface_get_name(s),
|
||||||
|
})
|
||||||
|
if fixed_total == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
var rebuilt := ArrayMesh.new()
|
||||||
|
for i in surfaces.size():
|
||||||
|
var entry: Dictionary = surfaces[i]
|
||||||
|
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"])
|
||||||
|
rebuilt.surface_set_material(i, entry["material"])
|
||||||
|
if entry["name"] != "":
|
||||||
|
rebuilt.surface_set_name(i, entry["name"])
|
||||||
|
mi.mesh = rebuilt
|
||||||
|
return fixed_total
|
||||||
|
|
||||||
|
|
||||||
|
## -1 left, +1 right, 0 not a leg bone — keyed by SKIN BIND index, which is what
|
||||||
|
## ARRAY_BONES actually stores (not the skeleton's bone index).
|
||||||
|
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
|
||||||
|
var out := PackedInt32Array()
|
||||||
|
out.resize(skin.get_bind_count())
|
||||||
|
for b in skin.get_bind_count():
|
||||||
|
var n := _bind_name(skin, skeleton, b)
|
||||||
|
var is_leg := false
|
||||||
|
for hint in LEG_BONE_HINTS:
|
||||||
|
if n.findn(hint) != -1:
|
||||||
|
is_leg = true
|
||||||
|
break
|
||||||
|
if not is_leg:
|
||||||
|
out[b] = 0
|
||||||
|
elif n.ends_with(".L"):
|
||||||
|
out[b] = -1
|
||||||
|
elif n.ends_with(".R"):
|
||||||
|
out[b] = 1
|
||||||
|
else:
|
||||||
|
out[b] = 0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## bind index -> bind index of the same bone on the opposite side.
|
||||||
|
static func _mirror_map(skin: Skin, skeleton: Skeleton3D) -> Dictionary:
|
||||||
|
var by_name := {}
|
||||||
|
for b in skin.get_bind_count():
|
||||||
|
by_name[_bind_name(skin, skeleton, b)] = b
|
||||||
|
var out := {}
|
||||||
|
for b in skin.get_bind_count():
|
||||||
|
var n := _bind_name(skin, skeleton, b)
|
||||||
|
var other := ""
|
||||||
|
if n.ends_with(".L"):
|
||||||
|
other = n.substr(0, n.length() - 2) + ".R"
|
||||||
|
elif n.ends_with(".R"):
|
||||||
|
other = n.substr(0, n.length() - 2) + ".L"
|
||||||
|
if other != "" and by_name.has(other):
|
||||||
|
out[b] = by_name[other]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _bind_name(skin: Skin, skeleton: Skeleton3D, b: int) -> String:
|
||||||
|
var n := skin.get_bind_name(b)
|
||||||
|
if n != "":
|
||||||
|
return n
|
||||||
|
var bone := skin.get_bind_bone(b)
|
||||||
|
return skeleton.get_bone_name(bone) if bone >= 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
||||||
|
mirror: Dictionary) -> int:
|
||||||
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||||
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
||||||
|
if bones.is_empty() or idx.is_empty() or verts.is_empty():
|
||||||
|
return 0
|
||||||
|
var per: int = bones.size() / verts.size()
|
||||||
|
|
||||||
|
# Dominant leg side per vertex (0 = not leg-driven, left alone).
|
||||||
|
var vside := PackedInt32Array()
|
||||||
|
vside.resize(verts.size())
|
||||||
|
for v in verts.size():
|
||||||
|
var best_w := 0.0
|
||||||
|
var best_s := 0
|
||||||
|
for k in per:
|
||||||
|
var w: float = weights[v * per + k]
|
||||||
|
if w > best_w:
|
||||||
|
best_w = w
|
||||||
|
best_s = side[bones[v * per + k]]
|
||||||
|
vside[v] = best_s
|
||||||
|
|
||||||
|
# Neighbour tallies over shared triangles.
|
||||||
|
var n_left := PackedInt32Array()
|
||||||
|
var n_right := PackedInt32Array()
|
||||||
|
n_left.resize(verts.size())
|
||||||
|
n_right.resize(verts.size())
|
||||||
|
for t in range(0, idx.size(), 3):
|
||||||
|
for i in 3:
|
||||||
|
var v: int = idx[t + i]
|
||||||
|
for j in 3:
|
||||||
|
if i == j:
|
||||||
|
continue
|
||||||
|
var o: int = idx[t + j]
|
||||||
|
if vside[o] == -1:
|
||||||
|
n_left[v] += 1
|
||||||
|
elif vside[o] == 1:
|
||||||
|
n_right[v] += 1
|
||||||
|
|
||||||
|
var fixed := 0
|
||||||
|
for v in verts.size():
|
||||||
|
if vside[v] == 0:
|
||||||
|
continue
|
||||||
|
var total: int = n_left[v] + n_right[v]
|
||||||
|
if total < 4:
|
||||||
|
continue # too little context to judge
|
||||||
|
var agree: int = n_left[v] if vside[v] == -1 else n_right[v]
|
||||||
|
var disagree: int = total - agree
|
||||||
|
if float(disagree) / float(total) < MAJORITY:
|
||||||
|
continue
|
||||||
|
# Overwhelmingly surrounded by the other leg: mirror this vertex's leg
|
||||||
|
# influences so it rides the leg its geometry actually belongs to.
|
||||||
|
var changed := false
|
||||||
|
for k in per:
|
||||||
|
var b: int = bones[v * per + k]
|
||||||
|
if side[b] == 0 or not mirror.has(b):
|
||||||
|
continue
|
||||||
|
bones[v * per + k] = mirror[b]
|
||||||
|
changed = true
|
||||||
|
if changed:
|
||||||
|
fixed += 1
|
||||||
|
if fixed > 0:
|
||||||
|
arrays[Mesh.ARRAY_BONES] = bones
|
||||||
|
return fixed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://72vc4gn33foy
|
||||||
@@ -56,12 +56,18 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
|
|||||||
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple",
|
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple",
|
||||||
"Dance", "PistolIdle"]
|
"Dance", "PistolIdle"]
|
||||||
|
|
||||||
const BLEND_TIME := 0.15
|
const BLEND_TIME := 0.22
|
||||||
## Per-clip blend overrides: snappy moves cut fast, locomotion cross-fades.
|
## Per-clip blend overrides. Reaction moves still need to read as instant, but
|
||||||
|
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
|
||||||
|
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
|
||||||
|
## drifts across their thresholds, and that is where hard cuts were most
|
||||||
|
## visible.
|
||||||
const BLEND_TIMES := {
|
const BLEND_TIMES := {
|
||||||
"Dash": 0.06, "Jump": 0.08, "Hit": 0.05, "Land": 0.08,
|
"Dash": 0.10, "Jump": 0.12, "Hit": 0.08, "Land": 0.12,
|
||||||
"Slide": 0.1, "Death": 0.1, "Throw": 0.06, "PistolReload": 0.12,
|
"Slide": 0.16, "Death": 0.15, "Throw": 0.10, "PistolReload": 0.18,
|
||||||
"Idle": 0.25, "PistolIdle": 0.25, "Walk": 0.2, "Run": 0.2, "Sprint": 0.2,
|
"Idle": 0.30, "PistolIdle": 0.30, "Walk": 0.28, "Run": 0.28, "Sprint": 0.28,
|
||||||
|
"CrouchIdle": 0.28, "CrouchWalk": 0.28, "Fall": 0.20, "WallRun": 0.22,
|
||||||
|
"WallCling": 0.20, "Grapple": 0.20,
|
||||||
}
|
}
|
||||||
|
|
||||||
## Named gameplay actions -> (clip, lock seconds). Networked via the
|
## Named gameplay actions -> (clip, lock seconds). Networked via the
|
||||||
@@ -93,9 +99,17 @@ var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS
|
|||||||
var _upper_total: float = 0.0 # its full duration, for progress 0..1
|
var _upper_total: float = 0.0 # its full duration, for progress 0..1
|
||||||
var _upper_action: String = "" # which ACTIONS entry is playing
|
var _upper_action: String = "" # which ACTIONS entry is playing
|
||||||
## Bone-name fragments that belong to the upper-body one-shot layer.
|
## Bone-name fragments that belong to the upper-body one-shot layer.
|
||||||
|
##
|
||||||
|
## NECK AND HEAD ARE DELIBERATELY EXCLUDED. The shared library's action clips
|
||||||
|
## were authored for a character looking at their own hands: PistolReload alone
|
||||||
|
## dives the head 33° and bends the whole neck chain 67° (measured). At that
|
||||||
|
## depth Taila's head/hair weights pinch and the skull visibly changes shape —
|
||||||
|
## which is what "reloading squishes the headshape" was. Nothing is actually
|
||||||
|
## scaled; no bone in any clip deviates from the rest pose by more than a
|
||||||
|
## millimetre. Leaving neck+head on the locomotion layer keeps the character
|
||||||
|
## looking downrange through a reload, which is also what a shooter wants.
|
||||||
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
|
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
|
||||||
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003",
|
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003"]
|
||||||
"neck", "head"]
|
|
||||||
|
|
||||||
# Grapple: world-space anchor the hook is attached to (drives the procedural
|
# Grapple: world-space anchor the hook is attached to (drives the procedural
|
||||||
# zip pose — body aligned to the line, free arm reaching for the point).
|
# zip pose — body aligned to the line, free arm reaching for the point).
|
||||||
@@ -148,6 +162,12 @@ func load_model(path: String) -> void:
|
|||||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||||
else:
|
else:
|
||||||
_ensure_meshes_bound(scene)
|
_ensure_meshes_bound(scene)
|
||||||
|
# Stray cross-body leg weights make the two ankle cuffs look welded
|
||||||
|
# together as the legs separate. See SkinWeightRepair.
|
||||||
|
var reweighted := SkinWeightRepair.repair(scene, skeleton)
|
||||||
|
if reweighted > 0:
|
||||||
|
print("SkinnedPlayerModel: re-weighted %d cross-leg vertices in '%s'"
|
||||||
|
% [reweighted, path.get_file()])
|
||||||
_pose_mod = ShooterPoseModifier.new()
|
_pose_mod = ShooterPoseModifier.new()
|
||||||
_pose_mod.name = "ShooterPose"
|
_pose_mod.name = "ShooterPose"
|
||||||
skeleton.add_child(_pose_mod)
|
skeleton.add_child(_pose_mod)
|
||||||
@@ -237,8 +257,10 @@ func _setup_anim_tree(scene: Node) -> void:
|
|||||||
bt.connect_node("loco_scale", 0, "loco")
|
bt.connect_node("loco_scale", 0, "loco")
|
||||||
|
|
||||||
var upper := AnimationNodeOneShot.new()
|
var upper := AnimationNodeOneShot.new()
|
||||||
upper.fadein_time = 0.08
|
# The upper-body one-shot pops in and out over the locomotion clip, so its
|
||||||
upper.fadeout_time = 0.15
|
# own fades matter as much as the locomotion cross-fade.
|
||||||
|
upper.fadein_time = 0.14
|
||||||
|
upper.fadeout_time = 0.22
|
||||||
upper.filter_enabled = true
|
upper.filter_enabled = true
|
||||||
_upper_anim = AnimationNodeAnimation.new()
|
_upper_anim = AnimationNodeAnimation.new()
|
||||||
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
|
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dacludfhqj3fb
|
||||||
Reference in New Issue
Block a user