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
3 changed files with 191 additions and 0 deletions
Showing only changes of commit 050011666c - Show all commits
+176
View File
@@ -0,0 +1,176 @@
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
@@ -0,0 +1 @@
uid://cxgs0k8sy6jcg
+14
View File
@@ -86,6 +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 = []
var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node;
@@ -168,7 +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)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.knee_helpers = _knee_helpers
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
@@ -755,6 +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 = []
# 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
@@ -813,6 +821,12 @@ 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)
# Upper body follows the camera pitch: distributed over spine/neck/head
# so looking up/down reads on the whole silhouette, not just the head.