259 lines
10 KiB
GDScript
259 lines
10 KiB
GDScript
extends Object
|
|
class_name SkinJointHelper
|
|
|
|
## Articulates a joint through a CHAIN of helper bones so the limb cannot pinch.
|
|
##
|
|
## Linear-blend skinning averages TRANSFORMS, not shapes. Blending two rotations
|
|
## that are θ apart shortens anything perpendicular to the bend by cos(θ/2), so a
|
|
## vertex sitting between the thigh and the shin is pulled inside the leg the
|
|
## moment the knee folds. That is the thigh going thin during a jump.
|
|
##
|
|
## The collapse depends only on the WIDEST angle any single vertex has to blend
|
|
## across, so the fix is to make that angle small. Insert SEGMENTS-1 helper bones
|
|
## through the joint, each driven to its fraction of the child's rotation, and
|
|
## give every blend-zone vertex to the two ADJACENT bones it falls between. No
|
|
## vertex then blends across more than θ/SEGMENTS, and the worst collapse drops
|
|
## from cos(θ/2) to cos(θ/2·SEGMENTS):
|
|
##
|
|
## θ = 120° 1 step (raw) 0.50 2 steps (half-angle) 0.87
|
|
## 4 steps 0.97 6 steps 0.985
|
|
##
|
|
## Nothing is scaled and nothing is snapped rigid — the limb is simply
|
|
## articulated finely enough that averaging no longer eats it. This is why the
|
|
## fix is more joints rather than volume-correction: a bone scale fattens every
|
|
## vertex on the bone whether it was collapsing or not (tried; it ballooned parts
|
|
## of the thigh to 167% while the worst vertices stayed put).
|
|
##
|
|
## KNEES ONLY. Hip and ankle helpers were tried twice and measured worse both
|
|
## times — the hip swings rather than folds, so it was adding a blend for
|
|
## nothing, and it dragged the skirt down with it (0.80 -> 0.72). Re-measure
|
|
## before adding a joint here.
|
|
##
|
|
## install() returns [child_bone, helper_bone, fraction] triples; the caller MUST
|
|
## drive them every frame from inside the skeleton's modification pass (see
|
|
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
|
|
## with its child deforms the limb instead of saving it.
|
|
|
|
## 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
|
|
## A vertex needs at least this much of BOTH bones before it is worth moving.
|
|
const MIN_BLEND := 0.02
|
|
## And the joint must hold at least this share of the vertex overall, or it is a
|
|
## stray cross-body influence rather than the joint this vertex belongs to.
|
|
const DOMINANT_SHARE := 0.5
|
|
|
|
|
|
## Returns an Array of [child_bone_idx, helper_bone_idx, fraction].
|
|
static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
|
if skeleton == null:
|
|
return []
|
|
var driven: Array = []
|
|
# child bone -> [parent bone, [helper bones, inner first]]
|
|
var joint_helpers := {}
|
|
for child_name in JOINT_CHILDREN:
|
|
var child := skeleton.find_bone(child_name)
|
|
if child < 0:
|
|
continue
|
|
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, child_name]
|
|
skeleton.add_bone(hname)
|
|
var h := skeleton.find_bone(hname)
|
|
skeleton.set_bone_parent(h, parent)
|
|
# Same rest as the child, so "this fraction of the child's local
|
|
# rotation" lands the helper at that fraction of the bend.
|
|
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
|
|
skeleton.reset_bone_pose(h)
|
|
helpers.append(h)
|
|
driven.append([child, h, float(step) / float(SEGMENTS)])
|
|
joint_helpers[child] = [parent, helpers]
|
|
if driven.is_empty():
|
|
return []
|
|
|
|
var chain_l := _leg_chain(skeleton, ".L")
|
|
var chain_r := _leg_chain(skeleton, ".R")
|
|
var limb_radius := 0.09
|
|
if chain_l.size() > 0 and chain_r.size() > 0:
|
|
limb_radius = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02)
|
|
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
|
if mi.mesh == null or mi.skin == null:
|
|
continue
|
|
if mi.mesh.get_blend_shape_count() > 0:
|
|
continue # rebuilding would drop the blend shapes
|
|
_reweight(mi, skeleton, joint_helpers, chain_l, chain_r, limb_radius)
|
|
return driven
|
|
|
|
|
|
## Rest-pose joints down one leg, as a polyline for limb-membership tests.
|
|
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
|
|
var out := PackedVector3Array()
|
|
for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]:
|
|
var i := skeleton.find_bone(stem + suffix)
|
|
if i >= 0:
|
|
out.append(skeleton.get_bone_global_rest(i).origin)
|
|
return out
|
|
|
|
|
|
static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float:
|
|
var best := INF
|
|
for i in range(chain.size() - 1):
|
|
var a: Vector3 = chain[i]
|
|
var ab: Vector3 = chain[i + 1] - a
|
|
var l2: float = ab.length_squared()
|
|
var t: float = 0.0 if l2 < 0.000001 else clampf((p - a).dot(ab) / l2, 0.0, 1.0)
|
|
best = minf(best, p.distance_to(a + ab * t))
|
|
return best
|
|
|
|
|
|
## Drive the helpers. MUST run inside the skeleton's modification pass.
|
|
static func update(skeleton: Skeleton3D, driven: Array) -> void:
|
|
for d in driven:
|
|
skeleton.set_bone_pose_rotation(d[1], Quaternion.IDENTITY.slerp(
|
|
skeleton.get_bone_pose_rotation(d[0]), d[2]))
|
|
|
|
|
|
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
|
joint_helpers: Dictionary, chain_l: PackedVector3Array,
|
|
chain_r: PackedVector3Array, limb_radius: float) -> void:
|
|
var skin: Skin = mi.skin
|
|
var bone_of := {}
|
|
for b in skin.get_bind_count():
|
|
var bi := skin.get_bind_bone(b)
|
|
if bi < 0:
|
|
bi = skeleton.find_bone(skin.get_bind_name(b))
|
|
bone_of[b] = bi
|
|
var bind_of_bone := {}
|
|
for b in skin.get_bind_count():
|
|
bind_of_bone[bone_of[b]] = b
|
|
|
|
# Per joint: the ordered bind chain parent -> helpers -> child. Helpers share
|
|
# the child's bind pose because their global rests are identical.
|
|
var jobs: Array = []
|
|
for child in joint_helpers:
|
|
var parent: int = joint_helpers[child][0]
|
|
var helpers: Array = joint_helpers[child][1]
|
|
if not bind_of_bone.has(child) or not bind_of_bone.has(parent):
|
|
continue
|
|
var child_bind: int = bind_of_bone[child]
|
|
var chain: Array = [bind_of_bone[parent]]
|
|
for h in helpers:
|
|
skin.add_named_bind(skeleton.get_bone_name(h),
|
|
skin.get_bind_pose(child_bind))
|
|
chain.append(skin.get_bind_count() - 1)
|
|
chain.append(child_bind)
|
|
jobs.append(chain)
|
|
if jobs.is_empty():
|
|
return
|
|
|
|
var surfaces: Array = []
|
|
var touched := 0
|
|
for s in range(mi.mesh.get_surface_count()):
|
|
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
|
touched += _reweight_surface(arrays, jobs, chain_l, chain_r, limb_radius)
|
|
surfaces.append({
|
|
"arrays": arrays,
|
|
"material": mi.mesh.surface_get_material(s),
|
|
"name": mi.mesh.surface_get_name(s),
|
|
})
|
|
if touched == 0:
|
|
return
|
|
var rebuilt := ArrayMesh.new()
|
|
for i in surfaces.size():
|
|
var e: Dictionary = surfaces[i]
|
|
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, e["arrays"])
|
|
rebuilt.surface_set_material(i, e["material"])
|
|
if e["name"] != "":
|
|
rebuilt.surface_set_name(i, e["name"])
|
|
mi.mesh = rebuilt
|
|
|
|
|
|
static func _reweight_surface(arrays: Array, jobs: Array,
|
|
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
|
|
limb_radius: float) -> int:
|
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
|
if bones.is_empty() or verts.is_empty():
|
|
return 0
|
|
var per: int = bones.size() / verts.size()
|
|
var touched := 0
|
|
|
|
for v in verts.size():
|
|
# Cloth is not a limb. The gate is generous — the outside of a knee sits
|
|
# further from the bone axis than the limb radius and DOES need helping —
|
|
# but without it the skirt and the outer boot flare get bound to a knee
|
|
# helper and swing with the shin.
|
|
if minf(_dist_to_chain(verts[v], chain_l),
|
|
_dist_to_chain(verts[v], chain_r)) > limb_radius * 1.6:
|
|
continue
|
|
# Pick the joint this vertex most belongs to — the chain holding the most
|
|
# of its weight — NOT merely the first chain that matches.
|
|
#
|
|
# Taking the first match bound right-thigh vertices that still carried a
|
|
# little residual left-leg weight to the LEFT knee's helpers, which then
|
|
# dragged them across the body: measured worst 0.46, and no amount of
|
|
# extra subdivision touched it because the vertex was on the wrong
|
|
# joint entirely.
|
|
var best_chain: Array = []
|
|
var best_kp := -1
|
|
var best_kc := -1
|
|
var best_total := 0.0
|
|
for chain in jobs:
|
|
var parent_bind: int = chain[0]
|
|
var child_bind: int = chain[chain.size() - 1]
|
|
var k_parent := -1
|
|
var k_child := -1
|
|
for k in per:
|
|
var b: int = bones[v * per + k]
|
|
if b == parent_bind:
|
|
k_parent = k
|
|
elif b == child_bind:
|
|
k_child = k
|
|
if k_parent < 0 or k_child < 0:
|
|
continue
|
|
var w_parent: float = weights[v * per + k_parent]
|
|
var w_child: float = weights[v * per + k_child]
|
|
if minf(w_parent, w_child) < MIN_BLEND:
|
|
continue
|
|
var total: float = w_parent + w_child
|
|
if total > best_total:
|
|
best_total = total
|
|
best_chain = chain
|
|
best_kp = k_parent
|
|
best_kc = k_child
|
|
# The joint must genuinely own this vertex. Below this it is a stray
|
|
# cross-body influence, and re-binding it would pull the mesh across.
|
|
if best_chain.is_empty() or best_total < DOMINANT_SHARE:
|
|
continue
|
|
# How far through the joint the vertex sits, then hand it to the two
|
|
# ADJACENT bones either side of that point. Its widest blend is now one
|
|
# segment instead of the whole joint.
|
|
var w_c: float = weights[v * per + best_kc]
|
|
var pos: float = (w_c / best_total) * float(SEGMENTS)
|
|
var lo: int = clampi(int(floor(pos)), 0, SEGMENTS - 1)
|
|
var frac: float = clampf(pos - float(lo), 0.0, 1.0)
|
|
bones[v * per + best_kp] = best_chain[lo]
|
|
weights[v * per + best_kp] = best_total * (1.0 - frac)
|
|
bones[v * per + best_kc] = best_chain[lo + 1]
|
|
weights[v * per + best_kc] = best_total * frac
|
|
touched += 1
|
|
if touched > 0:
|
|
arrays[Mesh.ARRAY_BONES] = bones
|
|
arrays[Mesh.ARRAY_WEIGHTS] = weights
|
|
return touched
|