Files
Papay-Shooter/characters/skinned_player_model.gd
T
Nicholas ButzkeandClaude Opus 5 27c4117c25 fix(pipeline): stand the character up before scaling; reach bones by role
Fixes the two root causes behind four of the seven reported breakages, and
rejects the source that cannot be fixed.

STAND UP FIRST. flatten_and_scale() now derives the up axis from the
skeleton and rotates the model upright before measuring anything. Aria and
Momo are correct: 1.75 m tall, 1.43 x 0.41 and 1.52 x 0.39 across, verified
by render.

The up vector is measured from the FEET to the HIPS, not from the hips to
the head. The head is not a reliable landmark — the spine walk ends on the
last non-cosmetic bone in the chain, which on a rig with a facial skeleton
can sit BELOW the hips. Momo's did, so the first cut of this fix stood her
neatly on her head: right size, right proportions, upside down. Feet cannot
be mistaken.

REACH BONES BY ROLE. SkinnedPlayerModel gained _role_bone(), and set_weapon
uses it. Four characters could not hold a gun because one hardcoded lookup
knew three spellings and their hands are called "Right wrist" and
"J_Bip_R_Hand" — both resolved perfectly in the sidecar the whole time.

HIKARI IS REJECTED. She now fails the gate: her feet and spine disagree
about which way is up, so the stand-up correction cannot resolve her
either, on top of zero-length cosmetic bones and a second armature that was
smuggling its own clips into the export. That is not a tuning problem, it
is a file that has been through two toolchains. De-registered and removed
rather than shipped broken — which is what the gate is for.

Six GLB skins remain, all passing. Smoke 0 failures, 11/11 movement tests.

Still open, recorded in the skill: kiyoko faces backwards, miku's grown
hair stretches under animation, the mannequin's rifle hold does not
convince, and taila's front skirt clipping.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 16:06:51 -04:00

1607 lines
69 KiB
GDScript

extends Node3D
class_name SkinnedPlayerModel
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
## with a Mixamo-compatible skeleton and the canonical animation set.
##
## Drop-in replacement for the procedural HumanoidModel:
## - update_state(state, speed, is_crouching) — drives animation selection
## - set_weapon(script_path) — third-person weapon in hand
## - shadows_only — legacy local-player mode
##
## View modes (for the LOCAL player only):
## - first_person_mode = true → model renders shadows-only for the owner, so
## the camera (which sits inside the head) never shows the inside of the
## mesh. Still fully animated; still visible to other players and in shadows.
## - Press the third-person toggle → set_owner_visible(true) makes the full
## animated model visible to the owner too (over-the-shoulder camera).
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@export var first_person_mode: bool = false
@export var shadows_only: bool = false
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
## Horizontal speed (m/s) at which the Run clip plays at authored speed.
@export var run_anim_reference_speed: float = 8.0
@export var walk_anim_reference_speed: float = 3.0
## Canonical clip names -> fallback chain. First clip that exists wins, so a
## model with only Idle/Walk/Run still animates in every movement state.
const CLIP_FALLBACKS := {
"Idle": ["Idle"],
"Walk": ["Walk", "Run", "Idle"],
"Run": ["Run", "Walk", "Idle"],
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
"Jump": ["Jump", "Fall", "Idle"],
"Fall": ["Fall", "Jump", "Idle"],
"Land": ["Land", "Idle"],
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
"WallRun": ["WallRun", "Run", "Walk"],
"WallCling": ["WallCling", "CrouchIdle", "Idle"],
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
"Dash": ["Dash", "Sprint", "Run", "Idle"],
"Death": ["Death", "Fall"],
"Hit": ["Hit", "Idle"],
"Dance": ["Dance", "Idle"],
"PistolIdle": ["PistolIdle", "Idle"],
"PistolShoot": ["PistolShoot"],
"PistolReload": ["PistolReload"],
"Throw": ["Throw", "Hit"],
}
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple",
"Dance", "PistolIdle"]
const BLEND_TIME := 0.32
## 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 := {
"Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16,
"Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "WallRun": 0.30,
"WallCling": 0.28, "Grapple": 0.28,
}
## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter.
const ACTIONS := {
"reload": ["PistolReload", 1.15],
"throw": ["Throw", 0.55],
"shoot": ["PistolShoot", 0.2],
"melee": ["Throw", 0.45], # overhand arm swing doubles as the knife slash
}
var skeleton: Skeleton3D
var animation_player: AnimationPlayer
var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
var _joint_helpers: Array = []
## Contents of <model>.rig.json — resolved bone roles, cloth chains, twist pairs
## and leg colliders, written by tools/retarget.py. Empty for a model that was
## rebound onto the library skeleton instead of keeping its own rig.
var _rig_info: Dictionary = {}
var _spring_mod: SpringBones
var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an
# AnimationNodeOneShot FILTERED to upper-body bones, so the legs keep
# sliding/running underneath instead of popping to the one-shot's standing legs.
var _anim_tree: AnimationTree
var _loco_trans: AnimationNodeTransition
var _upper_anim: AnimationNodeAnimation
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_action: String = "" # which ACTIONS entry is playing
## 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",
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003"]
# 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).
var _grapple_point_world: Vector3 = Vector3.ZERO
var _cur_grapple: float = 0.0
# Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
# the base clip by a SkeletonModifier3D so it composes with the animation.
var _pose_mod: ShooterPoseModifier
var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _target_wall: float = 0.0
var _cur_strafe: float = 0.0
var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
var _cur_wall: float = 0.0
## Which way the legs are actually travelling, in radians about the character's
## own up axis, and whether the locomotion cycle is running backwards to achieve
## it. See _update_travel.
var _cur_travel: float = 0.0
var _travel_reverse: bool = false
var _owner_visible: bool = false
## Horizontal speed from the last update_state, so the lean can scale with how
## fast the character is really moving.
var _speed: float = 0.0
var _loco_tier: int = 0
var _tier_age: float = 0.0
const POSE_SMOOTH := 10.0
## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant,
## so the posture arrives with the clip crossfade instead of a tenth of a second
## ahead of it.
const LEAN_SMOOTH := 4.5
## Ground locomotion tiers, slowest first, and how far below the promoting
## threshold the speed must fall before dropping back a tier.
const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"]
const LOCO_HYSTERESIS := 0.78
## A locomotion tier is held at least this long before another change is
## allowed. Hard acceleration genuinely passes through walking pace in about a
## fifth of a second, so without this Walk got 0.19 s — less than half of its own
## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still
## happen in one step, so this delays nothing that was not already a blur.
const MIN_TIER_DWELL := 0.22
func _ready() -> void:
if model_path != "":
load_model(model_path)
func load_model(path: String) -> void:
for child in get_children():
child.queue_free()
skeleton = null
animation_player = null
loaded = false
_resolved_clips.clear()
_current_clip = ""
_weapon_attachment = null
var scene := GLBLoader.load(path)
if not scene:
push_warning("SkinnedPlayerModel: failed to load '%s'" % path)
return
add_child(scene)
if facing_flip:
scene.rotation_degrees.y = 180.0
skeleton = _find_node_of_type(scene, "Skeleton3D") as Skeleton3D
animation_player = _find_node_of_type(scene, "AnimationPlayer") as AnimationPlayer
if not skeleton:
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
else:
_ensure_meshes_bound(scene)
_rig_info = _load_rig_info(path)
# `weights_authored` is MEASURED at build time, not inferred from which
# pipeline branch ran: a model that arrives unrigged still gets a
# sidecar, and its nearest-bone weights still need the repair below.
if not _rig_info.get("weights_authored", false):
# Weights were solved, not painted — every vertex went to its
# nearest four bones with no idea which limb it belongs to.
# SkinLegRepair exists solely to undo that, and it is destructive
# (it snaps weights and deletes triangles), so a model that kept its
# ARTIST weights must never be put through it.
var fixed := SkinLegRepair.repair(scene, skeleton)
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]])
# Joint subdivision runs for EVERY model, however it was rigged. It is
# not a weight repair: linear-blend skinning collapses any joint by
# cos(angle/2) no matter how good the weights are, and dropping it from
# the authored-weight path measured 0.77 at the knee against 0.99 with
# it. See SkinJointHelper.
_joint_helpers = SkinJointHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.joint_helpers = _joint_helpers
_pose_mod.roles = _rig_info.get("roles", {})
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
# Cloth and hair last, so the springs react to the FINAL body pose —
# animation plus the shooter lean/slide layer.
if not _rig_info.is_empty():
_spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones"
skeleton.add_child(_spring_mod)
var driven := _spring_mod.setup(skeleton, _rig_info,
_cloth_hulls(scene))
if driven == 0:
_spring_mod.queue_free()
_spring_mod = null
else:
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
% [path.get_file(), driven])
# Cel-shaded look: toon shading over the imported textures + ink outline.
LevelMaterials.apply_toon_recursive(scene)
# ...then the character-only pass: flat line-work + softer banding (see the
# function — toon-lighting the model's own outline shell is what put a white
# rim on every hair strand, and re-banding already-shaded textures read as
# gloss).
LevelMaterials.apply_character_look(scene)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
# The local owner renders shadows-only (the camera is inside the head, so
# showing the mesh would show the inside of it). Other players see it fully.
# The third-person toggle calls set_owner_visible(true) to reveal it.
if shadows_only or first_person_mode:
_set_shadows_recursive(self)
loaded = true
_play_clip("Idle")
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
## The sidecar carries ten farthest-point samples per cloth bone, which is a good
## description of a panel's OUTLINE and a poor one of a panel. Farthest-point
## sampling lands on corners, edges and the hem; a thigh comes up through the
## MIDDLE of a panel, between every sample, and the solver reported each frame's
## contacts fully resolved while 158 vertices sat 95 mm inside a leg.
##
## The runtime has the actual mesh, so it does not have to guess. Every vertex a
## cloth bone dominates is binned into a ~16 mm grid and one representative per
## cell is kept, which covers a panel evenly for a bounded number of points —
## unlike keeping every vertex, which would be thousands of collision tests per
## frame for no extra accuracy at the scale a limb is shaped.
const HULL_CELL := 0.020
const HULL_MAX := 14
func _cloth_hulls(scene: Node) -> Dictionary:
var cloth := {}
for c in _rig_info.get("chains", []):
for n in c.get("bones", []):
var bi := skeleton.find_bone(String(n))
if bi >= 0:
cloth[bi] = true
if cloth.is_empty():
return {}
# bone -> cell key -> the vertex nearest that cell's centre.
var cells := {}
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
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
for s in mi.mesh.get_surface_count():
var arrays: Array = mi.mesh.surface_get_arrays(s)
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():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
# A vertex belongs to whichever bone holds the largest share of
# it — that is the bone whose motion actually decides where it
# ends up, and so the bone that has to keep it out of a leg.
var best := 0.0
var bind := -1
for k in per:
var w: float = weights[v * per + k]
if w > best:
best = w
bind = bones[v * per + k]
if bind < 0 or best < 0.5:
continue
var bi: int = bone_of.get(bind, -1)
if not cloth.has(bi):
continue
# The bind pose maps a vertex straight into its bone's rest
# space, which is exactly the frame the solver poses hulls in.
var local: Vector3 = skin.get_bind_pose(bind) * verts[v]
var key := "%d_%d_%d" % [
int(round(local.x / HULL_CELL)),
int(round(local.y / HULL_CELL)),
int(round(local.z / HULL_CELL))]
if not cells.has(bi):
cells[bi] = {}
if not cells[bi].has(key):
cells[bi][key] = local
var out := {}
var total := 0
for bi in cells:
var pts: Array = cells[bi].values()
if pts.size() > HULL_MAX:
# Keep the OUTERMOST cells. What clips is the part of a panel
# furthest from the bone it hangs on, and the grid has already made
# sure those are spread over the whole sheet rather than clustered.
pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
pts = pts.slice(0, HULL_MAX)
var packed := PackedVector3Array()
for p in pts:
packed.append(p)
out[skeleton.get_bone_name(bi)] = packed
total += packed.size()
print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points"
% [out.size(), total])
return out
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
## authored weights, which is what decides whether the load-time weight repair
## below is needed at all.
func _load_rig_info(model_path: String) -> Dictionary:
var side := model_path.get_basename() + ".rig.json"
if not FileAccess.file_exists(side):
return {}
var text := FileAccess.get_file_as_string(side)
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
push_warning("SkinnedPlayerModel: could not parse '%s'" % side)
return {}
return parsed
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
## A correctly-exported GLB binds automatically, but if one imports with a skin
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
## pose (a permanent T-pose) while the skeleton animates invisibly. This repairs
## that at load time so a bad export degrades gracefully instead of T-posing.
func _ensure_meshes_bound(scene: Node) -> void:
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.skin == null:
continue # not a skinned mesh
if mi.skeleton.is_empty() or mi.get_node_or_null(mi.skeleton) != skeleton:
mi.skeleton = mi.get_path_to(skeleton)
## Map canonical clip names to whatever actually shipped in the GLB and set
## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void:
var available := animation_player.get_animation_list()
for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate)
if match_name != "":
_resolved_clips[canonical] = match_name
break
for canonical in LOOPING_CLIPS:
if _resolved_clips.has(canonical):
var anim := animation_player.get_animation(_resolved_clips[canonical])
if anim:
anim.loop_mode = Animation.LOOP_LINEAR
## Runtime blend tree:
## clips -> loco Transition -> TimeScale -> OneShot(upper filter) -> output
## The OneShot's filter holds every upper-body track, so reload/throw/hit
## replace arms+chest only while the locomotion clip keeps owning the legs.
func _setup_anim_tree(scene: Node) -> void:
var bt := AnimationNodeBlendTree.new()
_loco_trans = AnimationNodeTransition.new()
_loco_trans.xfade_time = BLEND_TIME
_loco_trans.allow_transition_to_self = true # lets Land restart itself
bt.add_node("loco", _loco_trans, Vector2(-200, 0))
# One input per unique clip, named by the clip so transition_request works.
var seen := {}
var idx := 0
for canonical in _resolved_clips:
var clip_name: String = _resolved_clips[canonical]
if seen.has(clip_name):
continue
seen[clip_name] = true
var an := AnimationNodeAnimation.new()
an.animation = clip_name
var node_id := "clip_%d" % idx
bt.add_node(node_id, an, Vector2(-500, idx * 60))
_loco_trans.add_input(clip_name)
bt.connect_node("loco", idx, node_id)
idx += 1
var ts := AnimationNodeTimeScale.new()
bt.add_node("loco_scale", ts, Vector2(0, 0))
bt.connect_node("loco_scale", 0, "loco")
var upper := AnimationNodeOneShot.new()
# The upper-body one-shot pops in and out over the locomotion clip, so its
# 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_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
bt.add_node("upper", upper, Vector2(220, 0))
bt.connect_node("upper", 0, "loco_scale")
bt.connect_node("upper", 1, "upper_clip")
bt.connect_node("output", 0, "upper")
# Filter = every track whose bone is upper-body. Track paths are identical
# across the library's clips, so sample any one of them.
var sample: Animation = animation_player.get_animation(_resolved_clips.values()[0])
for t in sample.get_track_count():
var p := sample.track_get_path(t)
var bone := String(p.get_concatenated_subnames())
for hint in UPPER_BONE_HINTS:
if bone.findn(hint) != -1:
upper.set_filter_path(p, true)
break
_anim_tree = AnimationTree.new()
_anim_tree.name = "AnimTree"
_anim_tree.tree_root = bt
scene.add_child(_anim_tree)
_anim_tree.anim_player = _anim_tree.get_path_to(animation_player)
_anim_tree.active = true
func _find_clip(available: PackedStringArray, wanted: String) -> String:
for name in available:
if name == wanted:
return name
# Tolerate library prefixes ("mixamo/Run") and case differences.
var wanted_lower := wanted.to_lower()
for name in available:
var base := name.get_slice("/", name.get_slice_count("/") - 1).to_lower()
if base == wanted_lower:
return name
return ""
# ── View modes ────────────────────────────────────────────────────────────────
## Show or hide the model to its OWNER. In first person we render shadows-only
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
## toggle calls this with on=true to reveal the full animated model. Either way
## the model keeps casting shadows and stays visible to other players.
func set_owner_visible(on: bool) -> void:
_owner_visible = on
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_shadow_mode_recursive(self, mode)
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_shadow_mode_recursive(child, mode)
# ── Animation state ───────────────────────────────────────────────────────────
var _prev_state: String = ""
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _dancing: bool = false
## Play a one-shot clip over locomotion for `lock_time` seconds.
## Whole-body moments (Land) briefly own the full skeleton; everything else
## (reload/throw/shoot/hit) plays on the UPPER BODY ONLY through the filtered
## OneShot node, so the legs keep doing whatever the movement state says —
## you can reload mid-slide and stay sliding.
const FULL_BODY_ONESHOTS := ["Land"]
func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical):
return
if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
_oneshot_lock = lock_time
_play_clip(canonical, true)
return
_upper_lock = lock_time
_upper_total = lock_time
_upper_anim.animation = _resolved_clips[canonical]
_anim_tree.set("parameters/upper/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
## Emote toggle (Dance). Shown while grounded and near-idle; any real
## movement breaks it (the controller clears the flag too).
func set_dancing(on: bool) -> void:
_dancing = on
## Play a named gameplay action (reload / throw / shoot) as a one-shot.
func play_action(action: String) -> void:
if ACTIONS.has(action):
_upper_action = action
play_oneshot(ACTIONS[action][0], ACTIONS[action][1])
## Aim pitch in radians (up positive) — the upper body follows the camera.
func set_aim_pitch(pitch: float) -> void:
if _pose_mod:
_pose_mod.aim_pitch = clampf(pitch, -1.2, 1.2)
## Kick the pose recoil (fires on every shot, local echo or remote replay).
func add_gun_recoil(strength: float = 1.0) -> void:
if _pose_mod:
_pose_mod.recoil = minf(_pose_mod.recoil + strength, 1.5)
## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
_speed = speed
if not loaded or not animation_player:
return
# One-shots (Land, Hit) own playback briefly.
if _oneshot_lock > 0.0:
_oneshot_lock -= get_process_delta_time()
if _oneshot_lock > 0.0:
_prev_state = state
return
# A heavy landing plays the Land one-shot before locomotion resumes.
if state in ["ground", "idle"] and _prev_state == "air" \
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
_oneshot_lock = 0.25
_play_clip("Land")
_prev_state = state
return
_prev_state = state
var clip := "Idle"
match state:
"ground", "idle":
if _dancing and speed < 0.5 and not is_crouching:
clip = "Dance"
elif is_crouching:
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
else:
clip = _loco_clip(speed)
# Armed idle uses the plain Idle clip — the rifle-hold pose layer
# owns the arms, so the odd arms-crossed PistolIdle base reads worse.
"air":
# Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
"slide":
clip = "Slide"
"wall_run":
clip = "WallRun"
"wall_cling", "wall_climb":
clip = "WallCling"
"grapple":
# No canned clip — the zip pose is procedural (body aligned to the
# line, free arm reaching). Fall gives the legs a natural airborne
# base instead of the library's horizontal swim.
clip = "Fall"
"dash":
clip = "Dash"
"death":
clip = "Death"
_play_clip(clip)
if _pose_mod:
_pose_mod.state = state
_pose_mod.weapon_held = is_holding_weapon
# Scale locomotion playback so feet keep up with actual movement speed.
if _anim_tree:
var s := 1.0
var is_loco := false
match clip:
"Walk", "CrouchWalk":
s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
is_loco = true
"Run", "Sprint", "WallRun":
s = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
is_loco = true
# Backpedalling: run the cycle BACKWARDS rather than moon-walking with
# the forward clip. The shared library has no authored reverse run, and
# a reversed stride reads correctly for a backpedal. The hip yaw in
# _update_travel picks the regime and points the legs to match, so the
# two must agree — reading a different threshold here used to leave a
# band where the feet ran one way and pointed the other.
if is_loco and _travel_reverse:
s = -s
_anim_tree.set("parameters/loco_scale/scale", s)
## Drives the procedural pose layer. Called by the controller each frame.
## strafe: -1 (moving left) .. +1 (moving right), relative to facing
## fwd: -1 (moving back) .. +1 (moving forward), relative to facing
## ads: 0 (hip) .. 1 (aiming down sights)
func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_strafe = clampf(strafe, -1.0, 1.0)
_target_fwd = clampf(fwd, -1.0, 1.0)
_target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none.
## Drives a whole-body lean into the wall.
func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## World-space point the grapple hook is anchored to. While the movement state
## is "grapple" the pose layer aligns the body along the line to this point
## and reaches the free hand toward it.
func set_grapple_target(point_world: Vector3) -> void:
_grapple_point_world = point_world
## How far away each cloth detail level starts, in metres. See SpringBones.lod —
## the solver is expensive enough that only the character being looked at can
## afford the full thing.
const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0]
var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
if not _pose_mod:
return
var t := 1.0 - exp(-POSE_SMOOTH * delta)
# The body lean gets its own, much slower rate, and is scaled by how fast the
# character is ACTUALLY moving rather than by which key is held.
#
# The controller passes a normalised input direction, so `fwd` jumps 0 -> 1
# the instant W is pressed. At the shared rate that planted the full forward
# lean in about a tenth of a second while the Idle->Run crossfade was still
# 0.4 s from finishing — the body snapped into a run posture ahead of the run
# cycle. Tying it to speed means the lean now grows as the character
# accelerates, and lands with the clip.
var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta)
var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0)
_cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t)
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
_update_travel(delta, drive)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.travel_yaw = _cur_travel
_pose_mod.ads = _cur_ads
_pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall
# Grapple zip pose: blend in while grappling; feed the modifier the
# direction to the anchor in skeleton space.
if _upper_lock > 0.0:
_upper_lock -= delta
if _upper_lock <= 0.0:
# Explicitly retire the one-shot. Its `active` parameter does NOT
# reliably clear on its own, and anything still treating the shot
# as live permanently disables the rifle hold — after one reload
# the character would hold the gun with clip arms forever.
_upper_action = ""
if _anim_tree:
_anim_tree.set("parameters/upper/request",
AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT)
var grapple_target := 1.0 if _pose_mod.state == "grapple" else 0.0
_cur_grapple = lerpf(_cur_grapple, grapple_target, t)
_pose_mod.grapple = _cur_grapple
if _cur_grapple > 0.01 and skeleton and _grapple_point_world != Vector3.ZERO:
var chest := skeleton.global_transform.origin + Vector3.UP * 1.2
var dir_world := _grapple_point_world - chest
if dir_world.length_squared() > 0.01:
_pose_mod.grapple_dir = \
(skeleton.global_transform.basis.inverse() * dir_world).normalized()
# Two-hand rifle hold: owns the arms whenever a weapon is held, EXCEPT when
# a one-shot clip (reload/throw/hit) or a full-body moment (Land lock,
# dance, death) needs the authored animation to read through. The upper
# one-shot's own `active` flag is the truth for how long it owns the arms.
var st: String = _pose_mod.state
# `_upper_lock` (a timer we own) is the authority on how long the one-shot
# owns the arms — NOT the OneShot node's `active` flag, which can stay
# true indefinitely and would strand the arms on the clip forever.
var reloading := _upper_action == "reload" and _upper_lock > 0.0
# A RELOAD must never hand the right arm to the clip: the gun is parented
# to that hand, and the library's pistol-reload rotates the wrist — which
# flipped the rifle upside-down (mag pointing at the sky) while the hand
# reached "down" for it. During a reload the hold keeps the gun steady and
# the support hand does the magazine work at the real mag well instead.
var clip_owns_arms := (_oneshot_lock > 0.0 or _upper_lock > 0.0 \
or _dancing or st == "death") and not reloading
var hold_r := 0.0
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
hold_r = 1.0
hold_l = 1.0
match st:
"slide":
hold_l = 0.0 # trailing arm braces the ground
"wall_run":
# The wall-side arm reaches for the wall.
if _cur_wall > 0.05:
hold_r = 0.0
elif _cur_wall < -0.05:
hold_l = 0.0
"grapple":
hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r
_pose_mod.hold_l_target = hold_l
# Reload progress drives the support hand's trip to the mag well.
var rl_target := 0.0
if reloading and _upper_total > 0.0:
rl_target = clampf(1.0 - (_upper_lock / _upper_total), 0.0, 1.0)
_pose_mod.reload_phase = rl_target
## Which way the LEGS should point, and whether the stride runs backwards.
##
## The shared clip library has one forward locomotion cycle and no strafe or
## backpedal clips, so a character sidestepping used to run forwards on the spot
## while sliding sideways — nothing in the animation said which way they were
## going, and a body lean was carrying the whole burden of telling the player.
##
## Turning the HIPS toward the travel direction is what actually says it, and it
## costs no new animation: the legs are children of the hips, so the whole stride
## turns with them, while the spine counter-rotates so the chest and the gun stay
## on the aim. It is the same split every third-person shooter uses, and the same
## one Hoyoverse's locked-on locomotion uses.
##
## Beyond about a right angle the hips cannot follow, so the cycle plays in
## REVERSE and the legs point the other way instead — a real backpedal rather
## than a moonwalk. Which regime is in force is hysteretic, and the yaw is eased
## rather than snapped, so switching between them reads as a pivot on the spot,
## which is what a person actually does there.
## The most the hips may turn away from where the character is facing.
##
## A right angle would point the legs exactly along a sidestep, but a person
## sidestepping does not stand with their hips square to their path — they open
## maybe half that and let the feet cross. Past this the silhouette stops reading
## as a shooter holding an aim and starts reading as someone who has turned round.
const MAX_TRAVEL_YAW := 0.95
const TRAVEL_SMOOTH := 9.0
const TRAVEL_REVERSE_IN := -0.35
const TRAVEL_REVERSE_OUT := -0.12
func _update_travel(delta: float, drive: float) -> void:
var st: float = _target_strafe
var fw: float = _target_fwd
if _travel_reverse:
if fw > TRAVEL_REVERSE_OUT:
_travel_reverse = false
elif fw < TRAVEL_REVERSE_IN:
_travel_reverse = true
var want := 0.0
if absf(st) > 0.01 or absf(fw) > 0.01:
# Skeleton space: the character faces +Z and character-right is -X, so a
# travel direction of (strafe right, forward) is (-strafe, 0, fwd). A yaw
# of `want` about +Y points the legs along it — or along the opposite of
# it when the stride is running backwards.
want = atan2(st, -fw) if _travel_reverse else atan2(-st, fw)
want = clampf(want, -MAX_TRAVEL_YAW, MAX_TRAVEL_YAW) * drive
# Shortest way round, so a pivot never takes the long route.
var d := wrapf(want - _cur_travel, -PI, PI)
_cur_travel += d * (1.0 - exp(-TRAVEL_SMOOTH * delta))
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
## slowly, and the distance query is not free either.
func _update_cloth_lod(delta: float) -> void:
if _spring_mod == null:
return
_lod_timer -= delta
if _lod_timer > 0.0:
return
_lod_timer = 0.25
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
var want := CLOTH_LOD_RANGES.size()
for i in CLOTH_LOD_RANGES.size():
if d < CLOTH_LOD_RANGES[i]:
want = i
break
_spring_mod.lod = want
## Locomotion clip for a ground speed, with HYSTERESIS.
##
## The thresholds used to be a bare elif chain, so a character accelerating from
## a standstill crossed all three in under a second and each crossfade cut off
## the one before it — and any speed hovering on a boundary flickered between
## two clips forever. Dropping back down needs the speed to fall well under the
## threshold that promoted it, so a tier, once entered, is committed to.
func _loco_clip(speed: float) -> String:
_tier_age += get_process_delta_time()
var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35]
var want := _loco_tier
while want < LOCO_TIERS.size() - 1 and speed > up[want]:
want += 1
while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS:
want -= 1
if want != _loco_tier and _tier_age >= MIN_TIER_DWELL:
_loco_tier = want
_tier_age = 0.0
return LOCO_TIERS[_loco_tier]
func _play_clip(canonical: String, restart: bool = false) -> void:
if not _anim_tree or not _resolved_clips.has(canonical):
return
var clip_name: String = _resolved_clips[canonical]
if not restart and _current_clip == clip_name:
return
_loco_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME)
_anim_tree.set("parameters/loco/transition_request", clip_name)
_current_clip = clip_name
## Current smoothed forward lean, 0..1. For debug/transition_check.gd.
func get_lean_debug() -> float:
return _cur_fwd
## Whether the locomotion cycle is running backwards, which points the stride
## the opposite way to the hips. For debug/travel_dir_check.gd.
func stride_reversed_debug() -> bool:
return _travel_reverse
## Clip currently playing. For debug/transition_check.gd.
func current_clip_debug() -> String:
return _current_clip
## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float:
var p := get_parent()
if p is CharacterBody3D:
return p.velocity.y
return 0.0
# ── Third-person weapon ───────────────────────────────────────────────────────
## Attach a weapon (by weapon script path) to the right hand bone so other
## players see what this player is holding. Mirrors HumanoidModel.set_weapon().
func set_weapon(script_path: String) -> void:
if _weapon_attachment:
_weapon_attachment.queue_free()
_weapon_attachment = null
is_holding_weapon = script_path != ""
if script_path == "" or not skeleton:
return
var script = load(script_path)
if not script:
return
var w = script.new()
w.name = "ThirdPersonWeapon"
w.set_meta("is_third_person_weapon", true)
w.ready.connect(func():
w.set_process(false)
w.set_process_input(false)
# Owner's first-person view must not see their own held weapon (it
# sits right in front of the lens as a huge blob) — shadows only,
# same as the body. Skip when the owner is already in third person
# (weapon swap while toggled), else the new weapon comes up invisible.
if (shadows_only or first_person_mode) and not _owner_visible:
_set_shadows_recursive(w)
# Undo the first-person viewmodel placement from the weapon's _ready:
# lie along the hand's grip, scaled down to character proportions.
w.position = Vector3(-0.02, 0.07, 0.0)
w.rotation_degrees = Vector3(0, 90, -90)
w.scale = Vector3(1.0, 1.0, 1.0)
# Tell the pose layer the gun's axes in hand-bone space so it can
# aim the wrist to point the muzzle exactly where the player looks.
if _pose_mod:
var b: Basis = w.transform.basis.orthonormalized()
_pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1)
_pose_mod.gun_up_hand = b * Vector3(0, 1, 0)
_measure_weapon(w)
)
var hand_idx := _role_bone("hand.R", ["RightHand", "Hand_R", "hand.R"])
if hand_idx >= 0:
_weapon_attachment = BoneAttachment3D.new()
_weapon_attachment.name = "WeaponAttachment"
skeleton.add_child(_weapon_attachment)
_weapon_attachment.bone_idx = hand_idx
_weapon_attachment.add_child(w)
else:
# No hand bone — hold it at chest height like the procedural model did.
w.ready.connect(func():
w.position = Vector3(-0.15, 1.0, 0.4)
w.rotation_degrees = Vector3(0, 180, 0)
)
add_child(w)
## The muzzle of the gun actually in this character's hand.
##
## Anything the WORLD sees — tracers, muzzle flash, the shot's audio position —
## has to originate here whenever the character model is what the viewer is
## looking at. The first-person viewmodel is parented to the camera, so ITS
## muzzle sits inside the player's head; using it in third person put the flash
## next to the character's shoulder.
func get_muzzle_node() -> Node3D:
if not _weapon_attachment or _weapon_attachment.get_child_count() == 0:
return null
var w := _weapon_attachment.get_child(0)
if "muzzle_flash" in w and w.muzzle_flash:
return w.muzzle_flash
return w as Node3D
## Seat the weapon in the hand and tell the pose layer where the support hand
## and stock are, using the weapon's OWN authored markers.
##
## This used to derive everything from mesh AABBs, which silently produced
## nonsense: the FBX guns report bind-pose bounds tens of metres across (the M4
## measured 24 m long), so the grip offset threw the gun 7.5 m in front of the
## character. Nothing about a mesh's bounding box is trustworthy here.
##
## The reliable data is already authored: WeaponManager places the first-person
## viewmodel's hands at GRIP_LOCAL and SUPPORT_LOCAL in weapon space, and every
## weapon sets muzzle_flash.position at its barrel tip. Third person simply
## reaches for the same points the viewmodel does.
func _measure_weapon(w: Node3D) -> void:
var grip: Vector3 = WeaponGrips.GRIP
var support: Vector3 = WeaponGrips.SUPPORT
var fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
# Put the GRIP — not the model origin — in the fist.
w.position -= w.transform.basis * grip
# Barrel length, grip to muzzle. Every weapon marks its own barrel tip.
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
if "muzzle_flash" in w and w.muzzle_flash:
muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1)
# Support hand: where the viewmodel's off hand rides — but NEVER past the
# muzzle. That offset is one constant for all weapons, so on a short gun
# (the MP7's barrel is only 0.30 m) it used to hang the support hand out in
# front of the barrel with nothing to hold, which is why some weapons did
# not look supported by the arms.
var fore: float = absf((support - grip).dot(fwd))
_pose_mod.gun_fore = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45)
# Stock: not authored anywhere, so derive it from the barrel. Half the
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
# pocket for every gun in the set.
_pose_mod.gun_stock = clampf(muzzle_dist * 0.5, 0.10, 0.40)
# ── Helpers ───────────────────────────────────────────────────────────────────
## A bone by its resolved ROLE, falling back to name matching.
##
## tools/rig_map.py resolves every rig to roles and writes them to the sidecar so
## that nothing downstream has to guess a bone name — but a hardcoded lookup here
## meant four characters could not hold a gun. Their hands resolve perfectly as
## "Right wrist" and "J_Bip_R_Hand"; none of them matches a spelling this file
## knew, so set_weapon fell through to parenting the weapon to the model root at
## a fixed chest offset, where it is not attached to the character at all.
##
## The fallback stays for a model with no sidecar. It must never be the first
## thing tried.
func _role_bone(role: String, fallbacks: Array) -> int:
var roles: Dictionary = _rig_info.get("roles", {})
var actual := String(roles.get(role, ""))
if actual != "" and skeleton:
var b := skeleton.find_bone(actual)
if b >= 0:
return b
return _find_bone(fallbacks)
func _find_bone(name_parts: Array) -> int:
if not skeleton:
return -1
for i in range(skeleton.get_bone_count()):
var bone_name := skeleton.get_bone_name(i)
for part in name_parts:
if bone_name.findn(part) != -1:
return i
return -1
func _find_node_of_type(node: Node, type_name: String) -> Node:
if node.is_class(type_name):
return node
for child in node.get_children():
var found := _find_node_of_type(child, type_name)
if found:
return found
return null
func _set_shadows_recursive(node: Node) -> void:
if node is GeometryInstance3D:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
for child in node.get_children():
_set_shadows_recursive(child)
# ── Procedural shooter pose layer ─────────────────────────────────────────────
#
# Runs after the AnimationPlayer each frame and layers shooter-feel poses on top
# of the base clip: lean into the movement direction, a slide that leans back and
# looks forward, and an always-held weapon that raises to ADS. All rotations are
# authored in the skeleton's own space (forward = +Z, up = +Y, character-right =
# -X) and converted into each bone's local pose, so they read intuitively.
class ShooterPoseModifier extends SkeletonModifier3D:
# Inputs, written by the owning SkinnedPlayerModel each frame.
var strafe: float = 0.0 # -1 left .. +1 right
var fwd: float = 0.0 # -1 back .. +1 forward
var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
# Yaw of the LOWER body about the character's up axis, in radians — which way
# the legs are actually travelling. See SkinnedPlayerModel._update_travel.
var travel_yaw: float = 0.0
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
var recoil: float = 0.0 # decaying shot kick
var state: String = "idle"
var weapon_held: bool = false
# Grapple zip: 0..1 blend + skeleton-space direction to the anchor point.
var grapple: float = 0.0
var grapple_dir: Vector3 = Vector3.ZERO
# Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0
var hold_l_target: float = 0.0
var _hold_r: float = 0.0
var _hold_l: float = 0.0
var _time: float = 0.0
# The attached gun's forward/up axes in hand-bone space (set on set_weapon).
var gun_fwd_hand: Vector3 = Vector3.ZERO
var gun_up_hand: Vector3 = Vector3.UP
# Measured gun geometry (metres from the grip): how far out the support
# hand rides, and how far back the stock butt reaches. See _measure_weapon.
var gun_fore: float = 0.26
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
# [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
# travelling — the library has one forward locomotion cycle and no strafe
# clips — so it has to be legible, not subtle.
## How much of the hip yaw the spine takes back, so the chest, the head and
## the gun stay pointed where the player is aiming. Not all of it: a real
## torso does follow the hips a little, and countering the whole thing makes
## the waist look broken.
const TRAVEL_COUNTER := 0.82
const LEAN_ROLL := 0.42
const LEAN_PITCH := 0.30
const SLIDE_BACK := 0.75 # torso lean-back during slide
const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward
const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide
const SLIDE_KNEE := 0.55 # shins straighten against the crouch clip's bend
const WALL_PITCH := 0.2 # forward drive lean during a wall run
const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall
const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
var _idx: Dictionary = {}
var _resolved := false
## Role -> actual bone name for THIS rig, from <model>.rig.json. Empty when
## the model was rebound onto the library skeleton, where the names below
## already match.
var roles: Dictionary = {}
func _resolve() -> void:
var skel := get_skeleton()
var names := SPINE + ["DEF-neck", "DEF-head",
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
# The names above are the LIBRARY skeleton's. A model that kept its own
# rig names things differently and three of them simply do not exist on
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
# no bone with "neck" in its name at all. Unresolved, every lean, aim
# pitch and slide head-lift below silently did nothing.
var alias := {}
if not roles.is_empty():
var neck: String = roles.get("neck", "")
var head: String = roles.get("head", "")
var torso: Array = []
for n in roles.get("spine", []):
if n != neck and n != head:
torso.append(n)
for i in mini(torso.size(), SPINE.size() - 1):
alias[SPINE[i + 1]] = torso[i]
for n in names:
# Canonical names are the role keys with the DEF- prefix, so the
# limbs, hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n))
var b := skel.find_bone(actual)
if b < 0:
b = skel.find_bone(n)
_idx[n] = b
_resolved = true
func _process_modification() -> void:
var skel := get_skeleton()
if not skel:
return
if not _resolved:
_resolve()
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
_time += delta
var t := 1.0 - exp(-HOLD_SMOOTH * delta)
_hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t)
_apply_lean(skel)
if absf(aim_pitch) > 0.01:
_apply_aim_pitch(skel)
if slide > 0.01:
_apply_slide(skel)
if absf(wall) > 0.01:
_apply_wall_lean(skel)
if grapple > 0.01 and grapple_dir != Vector3.ZERO:
_apply_grapple(skel)
if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel)
if recoil > 0.01:
_apply_recoil(skel)
recoil = lerpf(recoil, 0.0, 0.25)
# 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
# so looking up/down reads on the whole silhouette, not just the head.
func _apply_aim_pitch(skel: Skeleton3D) -> void:
# Positive camera pitch (looking up) arches the torso back.
var per := Quaternion.IDENTITY.slerp(
Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.55), 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var head_q := Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.45)
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
# Shot kick: the torso absorbs it. The MUZZLE rise is not applied here —
# it rides in the hold's aim direction (see `kick` in _apply_rifle_hold),
# so the IK carries BOTH hands up with the gun. Rotating the arms here
# instead would shove the support hand straight off the handguard.
func _apply_recoil(skel: Skeleton3D) -> void:
var k := recoil
var back := Quaternion(Vector3(1, 0, 0), -0.05 * k)
for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back)
# Turn the lower body to face the way the character is travelling, and lean
# along that direction rather than along the facing.
#
# The legs hang off the hips, so yawing the hips turns the whole stride — the
# one thing that makes a sidestep look like a sidestep when the clip library
# has only a forward run. The spine takes most of it back so the chest and the
# gun stay on the aim.
#
# The LEAN has to move into the travel frame with it. Leaning "forward" along
# the character's facing while the legs run off to one side leans them
# sideways relative to their own stride, which is exactly the sensation of a
# character being dragged rather than running.
func _apply_lean(skel: Skeleton3D) -> void:
var yaw := travel_yaw * (1.0 - slide)
# How hard the character is driving, regardless of which way. Signed
# `fwd` is wrong now that the legs turn: a sidestep has fwd near zero and
# should still lean into its own stride.
var effort := clampf(Vector2(strafe, fwd).length(), 0.0, 1.0)
var pitch := effort * LEAN_PITCH * (1.0 - slide)
if absf(pitch) < 0.001 and absf(yaw) < 0.001:
return
var turn := Quaternion(Vector3(0, 1, 0), yaw)
# Pitch about the axis ACROSS the direction of travel, not across the
# facing — see the comment above the function.
var lean := Quaternion((turn * Vector3(1, 0, 0)).normalized(), pitch)
# The hips carry the yaw, and a little of the lean.
_add_space(skel, _idx.get("DEF-hips", -1),
turn * Quaternion.IDENTITY.slerp(lean, 0.25))
# The torso unwinds the yaw and takes the rest of the lean.
var torso: Array = SPINE.slice(1)
var n := maxf(torso.size(), 1)
var back := Quaternion(Vector3(0, 1, 0), -yaw * TRAVEL_COUNTER)
var per_yaw := Quaternion.IDENTITY.slerp(back, 1.0 / n)
var per_lean := Quaternion.IDENTITY.slerp(lean, 0.75 / n)
for b in torso:
_add_space(skel, _idx.get(b, -1), per_yaw * per_lean)
# Grapple zip: the whole body pivots to fly along the line to the anchor,
# legs trail behind, and the FREE (left) hand reaches up the rope — the
# right hand keeps the rifle (the hold layer runs after this and owns it).
func _apply_grapple(skel: Skeleton3D) -> void:
var d := grapple_dir.normalized()
var fwd := Vector3(0, 0, 1)
if d.dot(fwd) > 0.999:
d = (d + Vector3(0.001, 0.001, 0)).normalized()
var arc := Quaternion(fwd, d)
# Align a good chunk of the body with the line, capped so a grapple
# point straight overhead doesn't fold the character in half.
var frac := 0.65
var ang := arc.get_angle()
if ang * frac > 1.15:
frac = 1.15 / ang
var per := Quaternion.IDENTITY.slerp(arc, grapple * frac / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Head keeps sighting the anchor point.
var head_q := Quaternion.IDENTITY.slerp(arc, grapple * 0.18)
_add_space(skel, _idx.get("DEF-neck", -1), head_q)
_add_space(skel, _idx.get("DEF-head", -1), head_q)
# Legs trail behind the flight line (Superman zip, not a swim).
var trail := Quaternion(Vector3(1, 0, 0), 0.45 * grapple)
_add_space(skel, _idx.get("DEF-thigh.R", -1), trail)
_add_space(skel, _idx.get("DEF-thigh.L", -1),
Quaternion(Vector3(1, 0, 0), 0.32 * grapple))
var straighten := Quaternion(Vector3(1, 0, 0), -0.25 * grapple)
_add_space(skel, _idx.get("DEF-shin.R", -1), straighten)
_add_space(skel, _idx.get("DEF-shin.L", -1), straighten)
# Free arm reaches along the rope toward the anchor.
var ua_dir := (d + Vector3(0.28, 0.1, 0.0)).normalized()
var g_fa := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L",
ua_dir, d, -0.2, grapple)
var hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l >= 0 and g_fa != Quaternion.IDENTITY:
var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
var fa_o := skel.get_bone_global_rest(fa_l).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(d, 0.4) * Quaternion(fa_rest_dir, d) * hand_rest_q
_set_global_rot(skel, hand_l, g_fa, g_hand, grapple)
# Wall run: roll into the wall, drive forward, inner arm reaches the wall.
func _apply_wall_lean(skel: Skeleton3D) -> void:
var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \
* Quaternion(Vector3(1, 0, 0), WALL_PITCH * absf(wall))
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Reach the wall-side arm out sideways (character-right = -X, so a
# negative Z rotation swings the down arm toward the right side).
var reach := Quaternion(Vector3(0, 0, 1), -WALL_ARM_OUT * wall)
if wall > 0.0:
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), reach)
else:
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), reach)
# Slide: torso leans back, head looks forward, legs kick out in front so
# it reads feet-first instead of "sitting in a crouch".
func _apply_slide(skel: Skeleton3D) -> void:
var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide)
var per := Quaternion.IDENTITY.slerp(back, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var up := Quaternion(Vector3(1, 0, 0), SLIDE_HEAD_UP * slide)
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(up, 0.5))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(up, 0.5))
# Legs: thighs swing forward (lead leg further), knees straighten.
var lead := Quaternion(Vector3(1, 0, 0), -SLIDE_LEG_FWD * slide)
var trail := Quaternion(Vector3(1, 0, 0), -SLIDE_LEG_FWD * 0.7 * slide)
_add_space(skel, _idx.get("DEF-thigh.R", -1), lead)
_add_space(skel, _idx.get("DEF-thigh.L", -1), trail)
var straighten := Quaternion(Vector3(1, 0, 0), SLIDE_KNEE * slide)
_add_space(skel, _idx.get("DEF-shin.R", -1), straighten)
_add_space(skel, _idx.get("DEF-shin.L", -1), straighten)
# Trailing arm braces back-and-down against the ground for balance.
var brace := Quaternion(Vector3(1, 0, 0), 0.9 * slide) \
* Quaternion(Vector3(0, 0, 1), -0.5 * slide)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), brace)
_add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide))
# ── Two-hand rifle hold ──────────────────────────────────────────────────
# The gun is parented to the RIGHT HAND bone, so where the hands go decides
# where the gun goes. We therefore place the WEAPON first — stock in the
# shoulder pocket, barrel down the aim line — then solve both arms with
# two-bone IK to the resulting grip and foregrip points. That is what makes
# the stock actually meet the shoulder and the support hand actually touch
# the handguard, instead of both arms waving at art-directed angles near it.
# Skeleton space: character faces +Z, up +Y, character-right -X.
# Muzzle tilt at low-ready. Kept shallow: at the old 0.38 rad (22 degrees)
# the gun read as dangling from the hands rather than being carried.
const GUN_PITCH_HIP := 0.16
# Where the butt of the stock sits, relative to the right shoulder joint.
# The pocket is on the FRONT of the shoulder, slightly inboard of the joint.
# Low-ready used to drop the butt 0.20 m to the ribs, which pulled the whole
# weapon off the shoulder and out of the arms — it never looked held. It now
# stays in the pocket and only relaxes slightly out of ADS.
const POCKET_ADS := Vector3(0.05, 0.01, 0.07) # in the shoulder pocket
const POCKET_HIP := Vector3(0.03, -0.07, 0.06) # still shouldered, relaxed
# Elbow bend hints (skeleton space). At the hip the firing elbow rides
# down by the ribs; shouldered it flares OUT and level (the classic
# "chicken wing"), which is what keeps the tight fold from folding the
# arm up behind the head. The support elbow always tucks under the gun.
const POLE_R_HIP := Vector3(-0.55, -0.85, -0.20)
const POLE_R_ADS := Vector3(-1.0, -0.25, -0.10)
const POLE_L_HIP := Vector3(0.45, -0.90, -0.10)
const POLE_L_ADS := Vector3(0.30, -0.95, -0.05)
const R_HAND_TWIST := 0.0
const L_HAND_TWIST := 0.5
func _apply_rifle_hold(skel: Skeleton3D) -> void:
var ua_r: int = _idx.get("DEF-upper_arm.R", -1)
var ua_l: int = _idx.get("DEF-upper_arm.L", -1)
if ua_r < 0:
return
var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
var kick := recoil * 0.12
# 1. The gun's line: pitched down at low-ready, on the camera line at
# ADS, kicked up by recoil.
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
* Vector3(0, 0, 1)).normalized()
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
# magazine always hangs DOWN.
var side := aim_dir.cross(Vector3.UP)
if side.length_squared() < 0.0001:
side = Vector3(-1, 0, 0)
side = side.normalized()
var gun_up := side.cross(aim_dir).normalized()
# 2. Anchor the stock at the shoulder, then walk out along the barrel.
var shoulder := skel.get_bone_global_pose(ua_r).origin
var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads)
var stock_pos := shoulder + pocket
var grip_pos := stock_pos + aim_dir * gun_stock
# The support hand rides as far out the handguard as it can actually
# REACH. Without this a long rifle puts the foregrip past the left
# arm's limit and the IK yanks the whole arm out straight.
var fore_dist := gun_fore
if ua_l >= 0:
var l_sh := skel.get_bone_global_pose(ua_l).origin
var reach_l := _arm_reach(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L") * 0.94
for _i in 5:
if grip_pos.distance_to(l_sh) > reach_l:
break # even the grip is out of reach; nothing to slide to
if (grip_pos + aim_dir * fore_dist).distance_to(l_sh) <= reach_l:
break
fore_dist *= 0.75
var fore_pos := grip_pos + aim_dir * fore_dist
# 3. Support hand goes to the mag well during a reload (under the
# receiver — the correct side), otherwise to the handguard.
var l_target := fore_pos
if reload_phase > 0.001:
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05
var p := reload_phase
if p < 0.30: # rip the mag straight down out of the well
l_target = mag_well.lerp(drop, p / 0.30)
elif p < 0.55: # reach down for a fresh one
l_target = drop
elif p < 0.80: # bring it back up and seat it
l_target = drop.lerp(mag_well, (p - 0.55) / 0.25)
else: # hand returns to the handguard
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
# 4. Solve both arms onto those points.
var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized()
var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized()
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
"DEF-hand.R", grip_pos, pole_r, _hold_r)
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L", l_target, pole_l, _hold_l)
if OS.has_environment("GUN_POSE_DEBUG"):
var hr: int = _idx.get("DEF-hand.R", -1)
var hl: int = _idx.get("DEF-hand.L", -1)
print("HOLD ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [
ads, _hold_r, _hold_l, reload_phase, gun_fore, gun_stock, fore_dist],
" sh=", shoulder, " grip=", grip_pos, " fore=", fore_pos,
" handR=", skel.get_bone_global_pose(hr).origin if hr >= 0 else "-",
" handL=", skel.get_bone_global_pose(hl).origin if hl >= 0 else "-",
" reachL=%.3f" % _arm_reach(skel, "DEF-upper_arm.L",
"DEF-forearm.L", "DEF-hand.L"))
# 5. Roll the gun hand so the BARREL lies on the aim line and the
# magazine points down — the gun's orientation comes entirely from
# this wrist, so it can never end up inverted.
if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \
and gun_fwd_hand.length_squared() > 0.5:
var hand: int = _idx.get("DEF-hand.R", -1)
if hand >= 0:
var arc := Quaternion(gun_fwd_hand.normalized(), aim_dir)
var up_now := arc * gun_up_hand.normalized()
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if up_flat.length_squared() > 0.0001:
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
# 6. Support hand: palm wraps the handguard, following its forearm.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0:
var hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l_idx: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l_idx >= 0:
var fa_o := skel.get_bone_global_rest(fa_l_idx).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized()
var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
# Point the palm along the barrel so the fingers close over it.
var g_hand := Quaternion(aim_dir, L_HAND_TWIST) \
* Quaternion(fa_rest_dir, aim_dir) * hand_rest_q
_set_global_rot(skel, hand_l, g_fa_l, g_hand, _hold_l)
## Straight-arm length of an arm chain, from the rest pose.
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String) -> float:
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return 0.5
var a := skel.get_bone_global_rest(ua).origin
var b := skel.get_bone_global_rest(fa).origin
var c := skel.get_bone_global_rest(hand).origin
return a.distance_to(b) + b.distance_to(c)
## Two-bone IK: rotate the upper arm + forearm so the HAND JOINT lands on
## `target` (skeleton space). `pole` biases which way the elbow breaks.
## Returns the forearm's achieved global rotation (IDENTITY when skipped).
func _ik_arm(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String, target: Vector3, pole: Vector3,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return Quaternion.IDENTITY
# Segment lengths come from the REST pose so they never drift.
var ua_rest := skel.get_bone_global_rest(ua).origin
var fa_rest := skel.get_bone_global_rest(fa).origin
var hand_rest := skel.get_bone_global_rest(hand).origin
var l1 := ua_rest.distance_to(fa_rest)
var l2 := fa_rest.distance_to(hand_rest)
if l1 < 0.0001 or l2 < 0.0001:
return Quaternion.IDENTITY
# The shoulder's CURRENT position (spine lean/aim pitch already moved it).
var root := skel.get_bone_global_pose(ua).origin
var to_target := target - root
var d := to_target.length()
if d < 0.0001:
return Quaternion.IDENTITY
var reach := to_target / d
d = clampf(d, absf(l1 - l2) + 0.002, l1 + l2 - 0.002)
# Law of cosines for the shoulder angle, then break the elbow toward
# the pole to pick one of the infinitely many solutions.
var cos_a := clampf((l1 * l1 + d * d - l2 * l2) / (2.0 * l1 * d), -1.0, 1.0)
var axis := reach.cross(pole)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3.UP)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3(1, 0, 0))
axis = axis.normalized()
var ua_dir := (Quaternion(axis, acos(cos_a)) * reach).normalized()
var elbow := root + ua_dir * l1
var fa_dir := target - elbow
fa_dir = fa_dir.normalized() if fa_dir.length_squared() > 0.00000001 else reach
var g_fa := _aim_chain(skel, ua_name, fa_name, ua_dir, fa_dir, 0.0, w)
if OS.has_environment("IK_DEBUG") and ua_name.ends_with(".R"):
var got_ua := skel.get_bone_global_pose(ua)
var got_fa := skel.get_bone_global_pose(fa)
var got_hand := skel.get_bone_global_pose(hand)
print("IK l1=%.3f l2=%.3f d=%.3f" % [l1, l2, d],
"\n root_want=", root, " ua_origin_got=", got_ua.origin,
"\n elbow_want=", elbow, " fa_origin_got=", got_fa.origin,
"\n hand_want=", target, " hand_got=", got_hand.origin,
"\n ua_dir=", ua_dir, " ua_dir_got=",
(got_fa.origin - got_ua.origin).normalized())
return g_fa
# Aim an upper-arm/forearm chain along the given directions with exact FK:
# desired global orientation = (shortest arc from the bone's rest line to
# the target dir, plus a twist about that line) ⊕ rest, each local pose
# derived against the parent's posed global so there is no drift.
# Returns the forearm's target global rotation (IDENTITY when skipped).
func _aim_chain(skel: Skeleton3D, ua_name: String, fa_name: String,
ua_dir: Vector3, fa_dir: Vector3, ua_twist: float,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
if ua < 0 or fa < 0:
return Quaternion.IDENTITY
# Bone lines at rest (upper arm -> forearm -> hand joint origins).
var ua_o := skel.get_bone_global_rest(ua).origin
var fa_o := skel.get_bone_global_rest(fa).origin
var fa_children := skel.get_bone_children(fa)
var fa_tip := skel.get_bone_global_rest(fa_children[0]).origin \
if fa_children.size() > 0 else fa_o + (fa_o - ua_o)
var ua_rest_dir := (fa_o - ua_o).normalized()
var fa_rest_dir := (fa_tip - fa_o).normalized()
var ua_rest_q := skel.get_bone_global_rest(ua).basis.get_rotation_quaternion()
var fa_rest_q := skel.get_bone_global_rest(fa).basis.get_rotation_quaternion()
# Desired global rotations.
var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q
var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q
# Convert both to LOCAL pose rotations. Godot composes a bone as
# global = parent_global * rest_local * pose_local
# so pose_local = rest_local⁻¹ * parent_global⁻¹ * global_target.
# (Dropping the rest_local⁻¹ term silently biases every bone by its
# rest orientation — which is why hand-tuned angles used to be needed.)
var parent := skel.get_bone_parent(ua)
var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \
if parent >= 0 else Quaternion.IDENTITY
_set_global_rot(skel, ua, g_parent, g_ua, w)
# The forearm hangs off the upper arm we just aimed, so its parent's
# global IS g_ua (using the cached pose here would lag a frame).
var fa_parent := skel.get_bone_parent(fa)
var g_fa_parent := g_ua if fa_parent == ua \
else skel.get_bone_global_pose(fa_parent).basis.get_rotation_quaternion()
_set_global_rot(skel, fa, g_fa_parent, g_fa, w)
if OS.has_environment("GUN_POSE_DEBUG") and ua_name.ends_with(".R"):
var ua_actual := skel.get_bone_global_pose(ua).basis.get_rotation_quaternion()
var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion()
print("CHAIN DEBUG w=", w,
" ua_t=", g_ua, " ua_a=", ua_actual,
" | fa_t=", g_fa, " fa_a=", fa_actual,
" | ua_dir_t=", ua_dir, " ua_dir_a=", ua_actual * (ua_rest_q.inverse() * ua_rest_dir))
return g_fa
func _blend_local(skel: Skeleton3D, idx: int, target: Quaternion, w: float) -> void:
skel.set_bone_pose_rotation(idx,
skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w))
## Give a bone the requested GLOBAL rotation, given its parent's global
## rotation. In Godot 4 a bone's POSE *is* its local transform (rest is
## merely the default), so this is a plain parent-relative conversion.
func _set_global_rot(skel: Skeleton3D, idx: int, g_parent: Quaternion,
g_target: Quaternion, w: float) -> void:
_blend_local(skel, idx, g_parent.inverse() * g_target, w)
# Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0:
return
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
var local := b.inverse() * q_space * b
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)