Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
5 changed files with 239 additions and 190 deletions
Showing only changes of commit 1ab7e99231 - Show all commits
+225
View File
@@ -0,0 +1,225 @@
extends Object
class_name SkinJointHelper
## Handles bending with JOINTS instead of letting the mesh collapse.
##
## Linear-blend skinning averages TRANSFORMS, not shapes. A vertex weighted half
## to the thigh and half to the shin is placed at the average of two rotations,
## and when the joint bends that average falls inside the limb — the leg pinches
## and reads as "the thighs get really thin". Measured per vertex against the
## 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
## joint we insert a helper bone that sits on the child's rest transform and is
## driven to HALF the child's rotation. Vertices in that joint's blend zone are
## re-weighted off the parent/child pair and onto the helper, so the sharpest
## blend any vertex sees is half the joint angle — and halving the angle roughly
## quarters the collapse. This is the standard half-angle (or "twist") joint
## used to make skinned limbs hold their volume.
##
## KNEES ONLY, deliberately. Adding hip (hips->thigh) and ankle (shin->foot)
## helpers was tried and reverted: it made the knee region measurably WORSE
## (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
## 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.
## [parent, child] per joint, both sides. The helper is parented to `parent` and
## takes `child`'s rest transform.
const JOINTS := [
["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"],
]
## A vertex needs at least this much of BOTH bones before it is worth moving.
const MIN_BLEND := 0.02
## Joints are for the LIMB only. A helper is driven to half its child's swing,
## which is right for a thigh but wrong for a skirt — cloth wants to hang from
## the hips, not follow half a leg swing. Moving skirt vertices onto the hip
## 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].
static func install(root: Node, skeleton: Skeleton3D) -> Array:
if skeleton == null:
return []
var driven: Array = []
# child bone -> [helper bone, parent bone]
var helper_of_child := {}
for joint in JOINTS:
var parent := skeleton.find_bone(joint[0])
var child := skeleton.find_bone(joint[1])
if parent < 0 or child < 0:
continue
var hname: String = "HELPER-" + joint[1]
if skeleton.find_bone(hname) >= 0:
continue # already installed
skeleton.add_bone(hname)
var h := skeleton.find_bone(hname)
skeleton.set_bone_parent(h, parent)
# Same rest as the child, so "half the child's local rotation" lands the
# helper exactly halfway through the bend.
skeleton.set_bone_rest(h, skeleton.get_bone_rest(child))
skeleton.reset_bone_pose(h)
helper_of_child[child] = [h, parent]
driven.append([child, h])
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, helper_of_child, 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 LIMB_HINTS:
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]), 0.5))
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
helper_of_child: 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
# One bind per helper, sharing the child's bind pose — their global rests
# are identical, so the inverse-bind matrix is the same.
# joint list of [parent_bind, child_bind, helper_bind]
var jobs: Array = []
for child in helper_of_child:
var helper: int = helper_of_child[child][0]
var parent: int = helper_of_child[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]
skin.add_named_bind(skeleton.get_bone_name(helper),
skin.get_bind_pose(child_bind))
jobs.append([bind_of_bone[parent], child_bind, skin.get_bind_count() - 1])
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():
# NO limb-radius gate here. It was added to keep a hip helper off the
# skirt, and with knees only there is nothing to protect against — but
# it does exclude the outside of the knee, which sits further from the
# bone axis than the limb radius and is exactly what needs helping
# (measured: gating it put the knee back to 0.50).
for job in jobs:
var parent_bind: int = job[0]
var child_bind: int = job[1]
var helper_bind: int = job[2]
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]
var m: float = minf(w_parent, w_child)
if m < MIN_BLEND:
continue
# Move 2m onto the helper, taking m from each side. The slot whose
# weight falls to zero is reused, so no extra influence slot is
# needed and the weights still sum to exactly 1.
if w_parent <= w_child:
bones[v * per + k_parent] = helper_bind
weights[v * per + k_parent] = 2.0 * m
weights[v * per + k_child] = w_child - m
else:
bones[v * per + k_child] = helper_bind
weights[v * per + k_child] = 2.0 * m
weights[v * per + k_parent] = w_parent - m
touched += 1
# ONE joint per vertex. Falling through to the next job would read
# the bones this one just rewrote and move the vertex twice, which
# corrupts the blend rather than halving it (measured: the knee got
# worse, 0.80 -> 0.50, purely from cascading).
break
if touched > 0:
arrays[Mesh.ARRAY_BONES] = bones
arrays[Mesh.ARRAY_WEIGHTS] = weights
return touched
+1
View File
@@ -0,0 +1 @@
uid://dkypjuyv81kg7
-176
View File
@@ -1,176 +0,0 @@
extends Object
class_name SkinKneeHelper
## Fixes the knee "candy wrapper" collapse with a half-angle helper bone.
##
## Linear-blend skinning averages TRANSFORMS, not shapes. A vertex sitting half
## on the thigh and half on the shin is placed at the average of two rotations,
## and when the knee bends hard that average falls well inside the leg — the
## limb pinches to a flat ribbon. Measured on Taila per vertex (ring averages
## hide this completely): 532 leg vertices lose more than 20% of their radius
## and the worst lose 40%, all at y=0.54, which is exactly the knee, and worst
## in the air/jump pose. That is the squashing visible from the side while
## running and jumping.
##
## The standard fix is to stop any vertex blending across the full bend. We add
## a bone at the knee, parented to the thigh, holding the shin's rest transform,
## and drive it every frame to HALF the shin's rotation. Vertices in the blend
## zone are re-weighted off the thigh/shin pair and onto this helper, so the
## sharpest blend any vertex sees is half the knee angle. Halving the angle
## roughly quarters the collapse.
##
## install() returns the [shin_bone, helper_bone] pairs; the caller must drive
## them every frame from inside the skeleton's modification pass (see
## SkinnedPlayerModel.ShooterPoseModifier), because a helper bone that is not
## updated in step with the shin is worse than none at all.
const PAIRS := [["DEF-thigh.L", "DEF-shin.L"], ["DEF-thigh.R", "DEF-shin.R"]]
## Blend-zone threshold: a vertex needs at least this much of BOTH bones before
## it is worth moving onto the helper.
const MIN_BLEND := 0.02
## Returns an Array of [shin_bone_idx, helper_bone_idx].
static func install(root: Node, skeleton: Skeleton3D) -> Array:
if skeleton == null:
return []
var driven: Array = []
var helper_of_shin := {} # shin bone idx -> helper bone idx
for pair in PAIRS:
var thigh := skeleton.find_bone(pair[0])
var shin := skeleton.find_bone(pair[1])
if thigh < 0 or shin < 0:
continue
var hname: String = "HELPER-knee" + pair[1].substr(pair[1].length() - 2)
if skeleton.find_bone(hname) >= 0:
continue # already installed
skeleton.add_bone(hname)
var h := skeleton.find_bone(hname)
skeleton.set_bone_parent(h, thigh)
# Same rest as the shin, so "half the shin's local rotation" lands the
# helper exactly halfway through the bend.
skeleton.set_bone_rest(h, skeleton.get_bone_rest(shin))
skeleton.reset_bone_pose(h)
helper_of_shin[shin] = h
driven.append([shin, h])
if driven.is_empty():
return []
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, helper_of_shin)
return driven
## Drive the helpers. MUST run inside the skeleton's modification pass.
static func update(skeleton: Skeleton3D, driven: Array) -> void:
for d in driven:
var shin: int = d[0]
var helper: int = d[1]
skeleton.set_bone_pose_rotation(helper,
Quaternion.IDENTITY.slerp(skeleton.get_bone_pose_rotation(shin), 0.5))
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
helper_of_shin: Dictionary) -> void:
var skin: Skin = mi.skin
# bind index -> skeleton bone
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
# Make sure every helper has a bind, sharing the shin's bind pose (their
# global rests are identical, so the inverse-bind matrix is the same).
var bind_of_bone := {}
for b in skin.get_bind_count():
bind_of_bone[bone_of[b]] = b
var helper_bind := {}
for shin in helper_of_shin:
var helper: int = helper_of_shin[shin]
if not bind_of_bone.has(shin):
continue
var pose: Transform3D = skin.get_bind_pose(bind_of_bone[shin])
skin.add_named_bind(skeleton.get_bone_name(helper), pose)
helper_bind[bind_of_bone[shin]] = skin.get_bind_count() - 1
var thigh_of_shin := {}
for shin in helper_of_shin:
thigh_of_shin[shin] = skeleton.get_bone_parent(shin)
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, bone_of, bind_of_bone, helper_bind,
helper_of_shin, thigh_of_shin)
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, bone_of: Dictionary,
bind_of_bone: Dictionary, helper_bind: Dictionary,
helper_of_shin: Dictionary, thigh_of_shin: Dictionary) -> 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():
for shin in helper_of_shin:
var shin_bind: int = bind_of_bone.get(shin, -1)
var thigh_bind: int = bind_of_bone.get(thigh_of_shin[shin], -1)
if shin_bind < 0 or thigh_bind < 0 or not helper_bind.has(shin_bind):
continue
# Find this vertex's thigh and shin slots.
var k_thigh := -1
var k_shin := -1
for k in per:
var b: int = bones[v * per + k]
if b == thigh_bind:
k_thigh = k
elif b == shin_bind:
k_shin = k
if k_thigh < 0 or k_shin < 0:
continue
var w_thigh: float = weights[v * per + k_thigh]
var w_shin: float = weights[v * per + k_shin]
var m: float = minf(w_thigh, w_shin)
if m < MIN_BLEND:
continue
# Move 2m onto the helper, taking m from each side. The slot whose
# weight goes to zero is reused, so no extra influence slot is
# needed and the weights still sum to exactly 1.
if w_thigh <= w_shin:
bones[v * per + k_thigh] = helper_bind[shin_bind]
weights[v * per + k_thigh] = 2.0 * m
weights[v * per + k_shin] = w_shin - m
else:
bones[v * per + k_shin] = helper_bind[shin_bind]
weights[v * per + k_shin] = 2.0 * m
weights[v * per + k_thigh] = w_thigh - m
touched += 1
if touched > 0:
arrays[Mesh.ARRAY_BONES] = bones
arrays[Mesh.ARRAY_WEIGHTS] = weights
return touched
-1
View File
@@ -1 +0,0 @@
uid://cxgs0k8sy6jcg
+13 -13
View File
@@ -86,8 +86,8 @@ var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D
## [shin_bone, helper_bone] pairs driven every frame by the pose modifier.
var _knee_helpers: Array = []
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
var _joint_helpers: Array = []
var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node;
@@ -170,11 +170,11 @@ func load_model(path: String) -> void:
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 the knees. Without them the knee pinches to
# a ribbon at a hard bend — see SkinKneeHelper.
_knee_helpers = SkinKneeHelper.install(scene, skeleton)
# Half-angle helper bones at every leg joint. Without them the limb
# pinches at a hard bend — see SkinJointHelper.
_joint_helpers = SkinJointHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.knee_helpers = _knee_helpers
_pose_mod.joint_helpers = _joint_helpers
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
@@ -761,8 +761,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0
# [shin_bone, helper_bone] pairs; see SkinKneeHelper.
var knee_helpers: Array = []
# [child_bone, helper_bone] pairs; see SkinJointHelper.
var joint_helpers: Array = []
# Tuning (radians). Positive pitch leans forward; positive roll leans right.
# The lean is the ONLY thing that tells a viewer which way this character is
@@ -821,11 +821,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_recoil(skel)
recoil = lerpf(recoil, 0.0, 0.25)
# Knees LAST, and inside the modification pass: the helper has to track
# whatever final rotation the shin ended up with, or it deforms the leg
# instead of saving it.
if not knee_helpers.is_empty():
SkinKneeHelper.update(skel, knee_helpers)
# Joints LAST, and inside the modification pass: each helper has to track
# whatever final rotation its child bone ended up with, or it deforms the
# limb instead of saving it.
if not joint_helpers.is_empty():
SkinJointHelper.update(skel, joint_helpers)
# Upper body follows the camera pitch: distributed over spine/neck/head