Feat/outline thickness and tp weapon hold #22
+113
-100
@@ -1,74 +1,80 @@
|
|||||||
extends Object
|
extends Object
|
||||||
class_name SkinJointHelper
|
class_name SkinJointHelper
|
||||||
|
|
||||||
## Handles bending with JOINTS instead of letting the mesh collapse.
|
## Articulates a joint through a CHAIN of helper bones so the limb cannot pinch.
|
||||||
##
|
##
|
||||||
## Linear-blend skinning averages TRANSFORMS, not shapes. A vertex weighted half
|
## Linear-blend skinning averages TRANSFORMS, not shapes. Blending two rotations
|
||||||
## to the thigh and half to the shin is placed at the average of two rotations,
|
## that are θ apart shortens anything perpendicular to the bend by cos(θ/2), so a
|
||||||
## and when the joint bends that average falls inside the limb — the leg pinches
|
## vertex sitting between the thigh and the shin is pulled inside the leg the
|
||||||
## and reads as "the thighs get really thin". Measured per vertex against the
|
## moment the knee folds. That is the thigh going thin during a jump.
|
||||||
## rest transforms, Taila's thigh was losing up to 20% of its radius along its
|
|
||||||
## whole length during a jump, not just at the knee.
|
|
||||||
##
|
##
|
||||||
## The fix is to stop any vertex ever blending across a full bend. At each leg
|
## The collapse depends only on the WIDEST angle any single vertex has to blend
|
||||||
## joint we insert a helper bone that sits on the child's rest transform and is
|
## across, so the fix is to make that angle small. Insert SEGMENTS-1 helper bones
|
||||||
## driven to HALF the child's rotation. Vertices in that joint's blend zone are
|
## through the joint, each driven to its fraction of the child's rotation, and
|
||||||
## re-weighted off the parent/child pair and onto the helper, so the sharpest
|
## give every blend-zone vertex to the two ADJACENT bones it falls between. No
|
||||||
## blend any vertex sees is half the joint angle — and halving the angle roughly
|
## vertex then blends across more than θ/SEGMENTS, and the worst collapse drops
|
||||||
## quarters the collapse. This is the standard half-angle (or "twist") joint
|
## from cos(θ/2) to cos(θ/2·SEGMENTS):
|
||||||
## used to make skinned limbs hold their volume.
|
|
||||||
##
|
##
|
||||||
## KNEES ONLY, deliberately. Adding hip (hips->thigh) and ankle (shin->foot)
|
## θ = 120° 1 step (raw) 0.50 2 steps (half-angle) 0.87
|
||||||
## helpers was tried and reverted: it made the knee region measurably WORSE
|
## 4 steps 0.97 6 steps 0.985
|
||||||
## (0.80 -> 0.50) and dragged the skirt down with it (0.80 -> 0.72). The knee is
|
|
||||||
## the joint that actually bends far enough to pinch; the hip mostly swings,
|
|
||||||
## which linear-blend skinning handles far better. Re-measure before adding a
|
|
||||||
## joint here — more helpers is not automatically better.
|
|
||||||
##
|
##
|
||||||
## install() returns the [child_bone, helper_bone] pairs; the caller MUST drive
|
## Nothing is scaled and nothing is snapped rigid — the limb is simply
|
||||||
## them every frame from inside the skeleton's modification pass (see
|
## 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
|
## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step
|
||||||
## with its child deforms the limb instead of saving it.
|
## with its child deforms the limb instead of saving it.
|
||||||
|
|
||||||
## [parent, child] per joint, both sides. The helper is parented to `parent` and
|
## [parent, child] per joint, both sides.
|
||||||
## takes `child`'s rest transform.
|
|
||||||
const JOINTS := [
|
const JOINTS := [
|
||||||
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
|
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-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.
|
## A vertex needs at least this much of BOTH bones before it is worth moving.
|
||||||
const MIN_BLEND := 0.02
|
const MIN_BLEND := 0.02
|
||||||
## Joints are for the LIMB only. A helper is driven to half its child's swing,
|
## And the joint must hold at least this share of the vertex overall, or it is a
|
||||||
## which is right for a thigh but wrong for a skirt — cloth wants to hang from
|
## stray cross-body influence rather than the joint this vertex belongs to.
|
||||||
## the hips, not follow half a leg swing. Moving skirt vertices onto the hip
|
const DOMINANT_SHARE := 0.5
|
||||||
## helper measurably made them worse (0.80 -> 0.72), so anything hanging clear
|
|
||||||
## of the leg's own bone chain by more than this is left alone.
|
|
||||||
const LIMB_HINTS := ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]
|
|
||||||
|
|
||||||
|
|
||||||
## Returns an Array of [child_bone_idx, helper_bone_idx].
|
## Returns an Array of [child_bone_idx, helper_bone_idx, fraction].
|
||||||
static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
||||||
if skeleton == null:
|
if skeleton == null:
|
||||||
return []
|
return []
|
||||||
var driven: Array = []
|
var driven: Array = []
|
||||||
# child bone -> [helper bone, parent bone]
|
# child bone -> [parent bone, [helper bones, inner first]]
|
||||||
var helper_of_child := {}
|
var joint_helpers := {}
|
||||||
for joint in JOINTS:
|
for joint in JOINTS:
|
||||||
var parent := skeleton.find_bone(joint[0])
|
var parent := skeleton.find_bone(joint[0])
|
||||||
var child := skeleton.find_bone(joint[1])
|
var child := skeleton.find_bone(joint[1])
|
||||||
if parent < 0 or child < 0:
|
if parent < 0 or child < 0:
|
||||||
continue
|
continue
|
||||||
var hname: String = "HELPER-" + joint[1]
|
if skeleton.find_bone("HELPER1-" + joint[1]) >= 0:
|
||||||
if skeleton.find_bone(hname) >= 0:
|
|
||||||
continue # already installed
|
continue # already installed
|
||||||
skeleton.add_bone(hname)
|
var helpers: Array = []
|
||||||
var h := skeleton.find_bone(hname)
|
for step in range(1, SEGMENTS):
|
||||||
skeleton.set_bone_parent(h, parent)
|
var hname: String = "HELPER%d-%s" % [step, joint[1]]
|
||||||
# Same rest as the child, so "half the child's local rotation" lands the
|
skeleton.add_bone(hname)
|
||||||
# helper exactly halfway through the bend.
|
var h := skeleton.find_bone(hname)
|
||||||
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
|
skeleton.set_bone_parent(h, parent)
|
||||||
skeleton.reset_bone_pose(h)
|
# Same rest as the child, so "this fraction of the child's local
|
||||||
helper_of_child[child] = [h, parent]
|
# rotation" lands the helper at that fraction of the bend.
|
||||||
driven.append([child, h])
|
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():
|
if driven.is_empty():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -82,14 +88,14 @@ static func install(root: Node, skeleton: Skeleton3D) -> Array:
|
|||||||
continue
|
continue
|
||||||
if mi.mesh.get_blend_shape_count() > 0:
|
if mi.mesh.get_blend_shape_count() > 0:
|
||||||
continue # rebuilding would drop the blend shapes
|
continue # rebuilding would drop the blend shapes
|
||||||
_reweight(mi, skeleton, helper_of_child, chain_l, chain_r, limb_radius)
|
_reweight(mi, skeleton, joint_helpers, chain_l, chain_r, limb_radius)
|
||||||
return driven
|
return driven
|
||||||
|
|
||||||
|
|
||||||
## Rest-pose joints down one leg, as a polyline for limb-membership tests.
|
## Rest-pose joints down one leg, as a polyline for limb-membership tests.
|
||||||
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
|
static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array:
|
||||||
var out := PackedVector3Array()
|
var out := PackedVector3Array()
|
||||||
for stem in LIMB_HINTS:
|
for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]:
|
||||||
var i := skeleton.find_bone(stem + suffix)
|
var i := skeleton.find_bone(stem + suffix)
|
||||||
if i >= 0:
|
if i >= 0:
|
||||||
out.append(skeleton.get_bone_global_rest(i).origin)
|
out.append(skeleton.get_bone_global_rest(i).origin)
|
||||||
@@ -108,27 +114,14 @@ static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float:
|
|||||||
|
|
||||||
|
|
||||||
## Drive the helpers. MUST run inside the skeleton's modification pass.
|
## Drive the helpers. MUST run inside the skeleton's modification pass.
|
||||||
##
|
|
||||||
## Two things happen per joint:
|
|
||||||
##
|
|
||||||
## ROTATION — the helper takes half the child's bend, so no vertex ever blends
|
|
||||||
## across the full angle.
|
|
||||||
##
|
|
||||||
## Scaling the helper to widen the joint back out was tried and removed. The
|
|
||||||
## geometry is right — a blend across θ thins by cos(θ/2), which is |q.w| — but a
|
|
||||||
## BONE scale is far too blunt an instrument for it: it fattens every vertex on
|
|
||||||
## the helper regardless of whether that vertex was collapsing, which ballooned
|
|
||||||
## parts of the thigh to 167% while the worst-collapsing vertices (which are not
|
|
||||||
## on the helper at all) did not move off 0.83. Per-vertex correction is what
|
|
||||||
## that idea needs, and a bone cannot express it.
|
|
||||||
static func update(skeleton: Skeleton3D, driven: Array) -> void:
|
static func update(skeleton: Skeleton3D, driven: Array) -> void:
|
||||||
for d in driven:
|
for d in driven:
|
||||||
skeleton.set_bone_pose_rotation(d[1],
|
skeleton.set_bone_pose_rotation(d[1], Quaternion.IDENTITY.slerp(
|
||||||
Quaternion.IDENTITY.slerp(skeleton.get_bone_pose_rotation(d[0]), 0.5))
|
skeleton.get_bone_pose_rotation(d[0]), d[2]))
|
||||||
|
|
||||||
|
|
||||||
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
||||||
helper_of_child: Dictionary, chain_l: PackedVector3Array,
|
joint_helpers: Dictionary, chain_l: PackedVector3Array,
|
||||||
chain_r: PackedVector3Array, limb_radius: float) -> void:
|
chain_r: PackedVector3Array, limb_radius: float) -> void:
|
||||||
var skin: Skin = mi.skin
|
var skin: Skin = mi.skin
|
||||||
var bone_of := {}
|
var bone_of := {}
|
||||||
@@ -141,19 +134,22 @@ static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
|||||||
for b in skin.get_bind_count():
|
for b in skin.get_bind_count():
|
||||||
bind_of_bone[bone_of[b]] = b
|
bind_of_bone[bone_of[b]] = b
|
||||||
|
|
||||||
# One bind per helper, sharing the child's bind pose — their global rests
|
# Per joint: the ordered bind chain parent -> helpers -> child. Helpers share
|
||||||
# are identical, so the inverse-bind matrix is the same.
|
# the child's bind pose because their global rests are identical.
|
||||||
# joint list of [parent_bind, child_bind, helper_bind]
|
|
||||||
var jobs: Array = []
|
var jobs: Array = []
|
||||||
for child in helper_of_child:
|
for child in joint_helpers:
|
||||||
var helper: int = helper_of_child[child][0]
|
var parent: int = joint_helpers[child][0]
|
||||||
var parent: int = helper_of_child[child][1]
|
var helpers: Array = joint_helpers[child][1]
|
||||||
if not bind_of_bone.has(child) or not bind_of_bone.has(parent):
|
if not bind_of_bone.has(child) or not bind_of_bone.has(parent):
|
||||||
continue
|
continue
|
||||||
var child_bind: int = bind_of_bone[child]
|
var child_bind: int = bind_of_bone[child]
|
||||||
skin.add_named_bind(skeleton.get_bone_name(helper),
|
var chain: Array = [bind_of_bone[parent]]
|
||||||
skin.get_bind_pose(child_bind))
|
for h in helpers:
|
||||||
jobs.append([bind_of_bone[parent], child_bind, skin.get_bind_count() - 1])
|
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():
|
if jobs.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -191,15 +187,28 @@ static func _reweight_surface(arrays: Array, jobs: Array,
|
|||||||
var touched := 0
|
var touched := 0
|
||||||
|
|
||||||
for v in verts.size():
|
for v in verts.size():
|
||||||
# NO limb-radius gate here. It was added to keep a hip helper off the
|
# Cloth is not a limb. The gate is generous — the outside of a knee sits
|
||||||
# skirt, and with knees only there is nothing to protect against — but
|
# further from the bone axis than the limb radius and DOES need helping —
|
||||||
# it does exclude the outside of the knee, which sits further from the
|
# but without it the skirt and the outer boot flare get bound to a knee
|
||||||
# bone axis than the limb radius and is exactly what needs helping
|
# helper and swing with the shin.
|
||||||
# (measured: gating it put the knee back to 0.50).
|
if minf(_dist_to_chain(verts[v], chain_l),
|
||||||
for job in jobs:
|
_dist_to_chain(verts[v], chain_r)) > limb_radius * 1.6:
|
||||||
var parent_bind: int = job[0]
|
continue
|
||||||
var child_bind: int = job[1]
|
# Pick the joint this vertex most belongs to — the chain holding the most
|
||||||
var helper_bind: int = job[2]
|
# 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_parent := -1
|
||||||
var k_child := -1
|
var k_child := -1
|
||||||
for k in per:
|
for k in per:
|
||||||
@@ -212,26 +221,30 @@ static func _reweight_surface(arrays: Array, jobs: Array,
|
|||||||
continue
|
continue
|
||||||
var w_parent: float = weights[v * per + k_parent]
|
var w_parent: float = weights[v * per + k_parent]
|
||||||
var w_child: float = weights[v * per + k_child]
|
var w_child: float = weights[v * per + k_child]
|
||||||
var m: float = minf(w_parent, w_child)
|
if minf(w_parent, w_child) < MIN_BLEND:
|
||||||
if m < MIN_BLEND:
|
|
||||||
continue
|
continue
|
||||||
# Move 2m onto the helper, taking m from each side. The slot whose
|
var total: float = w_parent + w_child
|
||||||
# weight falls to zero is reused, so no extra influence slot is
|
if total > best_total:
|
||||||
# needed and the weights still sum to exactly 1.
|
best_total = total
|
||||||
if w_parent <= w_child:
|
best_chain = chain
|
||||||
bones[v * per + k_parent] = helper_bind
|
best_kp = k_parent
|
||||||
weights[v * per + k_parent] = 2.0 * m
|
best_kc = k_child
|
||||||
weights[v * per + k_child] = w_child - m
|
# The joint must genuinely own this vertex. Below this it is a stray
|
||||||
else:
|
# cross-body influence, and re-binding it would pull the mesh across.
|
||||||
bones[v * per + k_child] = helper_bind
|
if best_chain.is_empty() or best_total < DOMINANT_SHARE:
|
||||||
weights[v * per + k_child] = 2.0 * m
|
continue
|
||||||
weights[v * per + k_parent] = w_parent - m
|
# How far through the joint the vertex sits, then hand it to the two
|
||||||
touched += 1
|
# ADJACENT bones either side of that point. Its widest blend is now one
|
||||||
# ONE joint per vertex. Falling through to the next job would read
|
# segment instead of the whole joint.
|
||||||
# the bones this one just rewrote and move the vertex twice, which
|
var w_c: float = weights[v * per + best_kc]
|
||||||
# corrupts the blend rather than halving it (measured: the knee got
|
var pos: float = (w_c / best_total) * float(SEGMENTS)
|
||||||
# worse, 0.80 -> 0.50, purely from cascading).
|
var lo: int = clampi(int(floor(pos)), 0, SEGMENTS - 1)
|
||||||
break
|
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:
|
if touched > 0:
|
||||||
arrays[Mesh.ARRAY_BONES] = bones
|
arrays[Mesh.ARRAY_BONES] = bones
|
||||||
arrays[Mesh.ARRAY_WEIGHTS] = weights
|
arrays[Mesh.ARRAY_WEIGHTS] = weights
|
||||||
|
|||||||
@@ -255,12 +255,14 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
|||||||
# is left blended so it can keep draping.
|
# is left blended so it can keep draping.
|
||||||
var dl: float = _dist_to_chain(verts[v], chain_l)
|
var dl: float = _dist_to_chain(verts[v], chain_l)
|
||||||
var dr: float = _dist_to_chain(verts[v], chain_r)
|
var dr: float = _dist_to_chain(verts[v], chain_r)
|
||||||
var near: float = minf(dl, dr)
|
if minf(dl, dr) > limb_radius:
|
||||||
var far: float = maxf(dl, dr)
|
continue # hugs neither chain — cloth, leave it alone
|
||||||
if near > limb_radius or far < near * 1.25:
|
# Side comes from WEIGHT, not from which chain is nearer. An earlier
|
||||||
continue # drapes over both, or hugs neither — leave it alone
|
# version also demanded one chain be 1.25x closer than the other,
|
||||||
keep = -1 if dl < dr else 1
|
# which skipped everything near the centre line — and that is exactly
|
||||||
vside[v] = keep
|
# where the damage was: inner-thigh vertices kept 21% of the OPPOSITE
|
||||||
|
# leg and were torn apart when the legs split (measured 0.46). A limb
|
||||||
|
# vertex belongs to whichever leg actually drives it.
|
||||||
# Drop the losing leg's influence and renormalise what remains.
|
# Drop the losing leg's influence and renormalise what remains.
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for k in per:
|
for k in per:
|
||||||
@@ -310,7 +312,46 @@ static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
|||||||
weights[v * per + k] /= sum
|
weights[v * per + k] /= sum
|
||||||
snapped += 1
|
snapped += 1
|
||||||
|
|
||||||
# Step 3 — drop triangles that still span the legs below the knee.
|
# Step 3 — belt and braces: NO vertex that sits on a limb may carry any
|
||||||
|
# weight from the opposite leg, at any height. The staged rules above each
|
||||||
|
# have their own guards and between them they were still letting inner-thigh
|
||||||
|
# vertices through with ~20% of the far leg, which tears them apart when the
|
||||||
|
# legs split (measured 0.46 — the worst collapse left on the model). This is
|
||||||
|
# unconditional and runs last so nothing can reintroduce it.
|
||||||
|
for v in verts.size():
|
||||||
|
var near_l: float = _dist_to_chain(verts[v], chain_l)
|
||||||
|
var near_r: float = _dist_to_chain(verts[v], chain_r)
|
||||||
|
# Generous radius: the limb radius is measured from the bone AXIS, so a
|
||||||
|
# vertex on the front or back of a thigh clears it easily, and those were
|
||||||
|
# exactly the ones slipping through with opposite-leg weight. The skirt
|
||||||
|
# hangs far enough out to stay outside even this.
|
||||||
|
if minf(near_l, near_r) > limb_radius * 1.6:
|
||||||
|
continue # cloth
|
||||||
|
var own: int = -1 if near_l < near_r else 1
|
||||||
|
# Prefer the leg that actually drives it; fall back to the nearer chain.
|
||||||
|
var wl2 := 0.0
|
||||||
|
var wr2 := 0.0
|
||||||
|
for k in per:
|
||||||
|
match side[bones[v * per + k]]:
|
||||||
|
-1: wl2 += weights[v * per + k]
|
||||||
|
1: wr2 += weights[v * per + k]
|
||||||
|
if maxf(wl2, wr2) > 0.0:
|
||||||
|
own = -1 if wl2 >= wr2 else 1
|
||||||
|
if minf(wl2, wr2) <= 0.0:
|
||||||
|
continue # already single-legged
|
||||||
|
var tot := 0.0
|
||||||
|
for k in per:
|
||||||
|
var b2: int = bones[v * per + k]
|
||||||
|
if side[b2] != 0 and side[b2] != own:
|
||||||
|
weights[v * per + k] = 0.0
|
||||||
|
tot += weights[v * per + k]
|
||||||
|
if tot > 0.0:
|
||||||
|
for k in per:
|
||||||
|
weights[v * per + k] /= tot
|
||||||
|
vside[v] = own
|
||||||
|
snapped += 1
|
||||||
|
|
||||||
|
# Step 4 — drop triangles that still span the legs below the knee.
|
||||||
var removed := 0
|
var removed := 0
|
||||||
if not idx.is_empty():
|
if not idx.is_empty():
|
||||||
var keep_idx := PackedInt32Array()
|
var keep_idx := PackedInt32Array()
|
||||||
|
|||||||
Reference in New Issue
Block a user