Files
Papay-Shooter/characters/skinned_player_model.gd
T
Nicholas ButzkeandClaude Opus 5 53f175ed6d feat(characters): say what every surface IS, and light it accordingly
A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.

The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.

All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.

What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.

That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.

Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:35:06 -04:00

1910 lines
82 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 = {}
## What each surface of this model IS — body, cloth, hair, accessory. Read from
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var is_holding_weapon: bool = false
## Which skin this is, so per-character hold tuning can be looked up. Set by
## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = ""
## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by
## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {}
# 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
# Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST
# character's sidecar.
_rig_info = {}
_surfaces = 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.fingers = _rig_info.get("fingers", {})
_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...
LevelMaterials.apply_toon_recursive(scene)
# ...then the character-only pass, which is where the surface table earns
# its keep: it says which surfaces are hair, cloth, body or an accessory, so
# each can take its own outline weight and banding instead of all of them
# taking numbers calibrated on skin. It also identifies the model's own
# line-work by name and weight rather than by "is it nearly black", which is
# what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces)
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
## Every [mesh, surface index] of this model belonging to one surface class —
## SkinSurfaces.BODY, CLOTH, HAIR or ACCESSORY.
##
## The point of separating a character into a body, garments and hair is that
## the game can then treat them differently, and it can only do that if it can
## ask which is which. This is that question. It is used by the rig lab to let
## an artist isolate a class, and it is what a damage flash on skin only, or a
## hidden hat, or a per-class LOD would be built on.
func surfaces_of(surface_class: String) -> Array:
var out: Array = []
if _surfaces == null:
return out
for mi in find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null:
continue
for s in mi.mesh.get_surface_count():
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
if _surfaces.resolve(mi.name, s, src)[0] == surface_class:
out.append([mi, s])
return out
## The surface table, for tools that want to show or edit it. May be null.
func surface_table() -> SkinSurfaces:
return _surfaces
## 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
## Push a new hold tuning table in and re-seat the weapon with it.
##
## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on
## the next frame, but weapon SIZE and the grip offset are baked into the
## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t
if _pose_mod:
_pose_mod.tune = t
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = Transform3D.IDENTITY
_measure_weapon(w)
## 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
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
# the normal case and means "use what the code derives".
if hold_tune.is_empty():
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
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)
# Seat the weapon in the hand with NO hand-relative rotation.
#
# There used to be a fixed `rotation_degrees = (0, 90, -90)` here, which
# is the offset that happens to be right for the library's own DEF-hand.R
# and is wrong for every rig whose hand bone has a different roll. A bone
# attachment is expressed in the BONE's axes, and no two rigs agree on
# those, so a constant here mounts the gun differently on every character
# — which is what "the hand mount points are totally wrong on all models"
# was.
#
# It does not need to be right, because the pose layer aims the gun by
# rotating the WRIST until the weapon's forward axis lies on the aim line
# (see _apply_rifle_hold). Handing it the identity means "the gun's
# forward is the hand bone's -Z", which is true by construction on any
# rig, and the wrist then absorbs whatever that bone's roll happens to be.
# The grip is placed at the bone's origin below, so the gun sits IN the
# hand rather than at a fixed offset from a differently-oriented bone.
w.transform = Transform3D.IDENTITY
if _pose_mod:
_pose_mod.gun_fwd_hand = Vector3(0, 0, -1)
_pose_mod.gun_up_hand = 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.
## How far a weapon may be scaled down before it reads as a toy.
const MIN_WEAPON_SCALE := 0.55
## Forward offset of the stock pocket from the shoulder joint — see POCKET_HIP.
const POCKET_FORWARD := 0.06
## Scale the weapon so this character can actually reach its handguard.
##
## Not against a fixed reference size: against the arm that has to hold it. The
## set is modelled at real-world scale — an M4 is 0.84 m butt to muzzle — and
## these characters are stylised, with arms around 0.47 m against an adult 0.52.
##
## The binding constraint is the SUPPORT arm. Its hand has to reach a point
## (stock + fore) in front of the shoulder pocket, from a shoulder half the
## shoulder-width off the weapon's axis. Solve that triangle for the largest gun
## whose handguard still lands inside the arm's reach, and the support hand stops
## being dragged back down the barrel.
##
## Measured on Taila before this: a support offset authored at 0.35 m collapsed
## to 0.083 m, which puts the support fist on top of the trigger fist — a
## two-handed pistol grip, not a rifle. That is what "the hands are not on the
## rails" was.
func _weapon_scale(span: float) -> float:
if not _pose_mod or not skeleton or span <= 0.01:
return 1.0
var reach: float = _pose_mod._arm_reach(skeleton, "DEF-upper_arm.L",
"DEF-forearm.L", "DEF-hand.L") * 0.94
if reach <= 0.01:
return 1.0
var ua_l: int = _pose_mod._idx.get("DEF-upper_arm.L", -1)
var ua_r: int = _pose_mod._idx.get("DEF-upper_arm.R", -1)
var lateral := 0.14
if ua_l >= 0 and ua_r >= 0:
# The weapon rides near the right shoulder, so the support arm crosses
# about half the shoulder width to get to it.
lateral = skeleton.get_bone_global_rest(ua_l).origin.distance_to(
skeleton.get_bone_global_rest(ua_r).origin) * 0.5
var forward := sqrt(maxf(reach * reach - lateral * lateral, 0.01))
return clampf((forward - POCKET_FORWARD) / span, MIN_WEAPON_SCALE, 1.0)
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
# 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))
var fore_u: float = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45)
var stock_u: float = clampf(muzzle_dist * 0.5, 0.10, 0.40)
# Size the gun to the arm that has to hold it, then seat the grip in the fist.
var k := _weapon_scale(stock_u + fore_u)
# A tuned size wins over the derived one. The derivation keeps the handguard
# inside the support arm's reach, which is a floor on believability, not a
# statement about how big the gun should LOOK.
if hold_tune.get("weapon_scale", 0.0) > 0.01:
k = float(hold_tune["weapon_scale"])
w.scale = Vector3.ONE * k
w.position -= w.transform.basis * grip
_pose_mod.gun_fore = fore_u * k
if hold_tune.get("gun_fore", 0.0) > 0.0001:
_pose_mod.gun_fore = float(hold_tune["gun_fore"])
# 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 = stock_u * k
if hold_tune.get("gun_stock", 0.0) > 0.0001:
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
_pose_mod.tune = hold_tune
# ── 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 = {}
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new
## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
## picks it up from the same table.
var tune: Dictionary = {}
## Where the hold put the weapon this frame, in skeleton space. The lab draws
## these so the points being tuned are visible rather than inferred.
var dbg_grip: Vector3 = Vector3.ZERO
var dbg_fore: Vector3 = Vector3.ZERO
var dbg_stock: Vector3 = Vector3.ZERO
func _t(key: String, fallback: float) -> float:
return float(tune.get(key, fallback))
func _tv(key: String, fallback: Vector3) -> Vector3:
var v = tune.get(key)
# A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It
# is how the elbow poles keep their hip/ADS blend unless overridden.
return v if (v is Vector3 and v.length() > 0.0001) else fallback
var _fing: Dictionary = {} # same, resolved to bone indices
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
var _hand_frame: 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
_resolve_hands(skel)
_resolved = true
## Finger bones, and the axis a finger closes about, per hand.
##
## The axis is derived from the hand's OWN ANATOMY in the rest pose, because
## no two rigs agree on finger bone orientation and a constant would close
## one character's hand and splay another's:
##
## along wrist -> middle knuckle, the length of the hand
## palm middle knuckle -> thumb tip, across it. The thumb opposes the
## fingers, so it is on the palm side by construction — which is
## true of a hand, not of a rig convention.
## curl along x palm, so turning about it swings the fingers into the
## palm rather than sideways or backwards.
func _resolve_hands(skel: Skeleton3D) -> void:
for key in fingers:
var list := PackedInt32Array()
for n in fingers[key]:
var b := skel.find_bone(String(n))
if b >= 0:
list.append(b)
if not list.is_empty():
_fing[key] = list
for side in ["L", "R"]:
var hand: int = _idx.get("DEF-hand." + side, -1)
var mid: PackedInt32Array = _fing.get("middle." + side, PackedInt32Array())
if hand < 0 or mid.is_empty():
continue
var wrist := skel.get_bone_global_rest(hand).origin
var knuckle := skel.get_bone_global_rest(mid[0]).origin
var along := knuckle - wrist
if along.length() < 0.0001:
continue
along = along.normalized()
var palm := Vector3.ZERO
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
if not thumb.is_empty():
palm = skel.get_bone_global_rest(thumb[thumb.size() - 1]).origin - knuckle
else:
# No thumb on this rig: fall back to the knuckle line, which
# gives a plane but not a side, so the sign may be wrong.
var ix: PackedInt32Array = _fing.get("index." + side, PackedInt32Array())
var pk: PackedInt32Array = _fing.get("pinky." + side, PackedInt32Array())
if ix.is_empty() or pk.is_empty():
continue
palm = (skel.get_bone_global_rest(pk[0]).origin
- skel.get_bone_global_rest(ix[0]).origin).cross(along)
palm -= along * palm.dot(along)
if palm.length() < 0.0001:
continue
palm = palm.normalized()
var curl := along.cross(palm).normalized()
_curl[side] = curl
# The whole hand as an ANATOMICAL FRAME, not just the curl axis.
#
# Orienting a hand onto something it is gripping is a frame-to-frame
# problem: the fingers have to wrap AROUND the object, so the curl
# axis must lie along the object's axis, and the palm has to face it.
# Both are answered at once by mapping this frame onto the target's.
# Building the rotation from a shortest arc plus a constant twist —
# which is what was here — leaves the roll about the barrel
# unspecified, so the support hand landed upside down.
_hand_frame[side] = Basis(along, palm, curl)
if OS.has_environment("HAND_DEBUG"):
print("HANDS fingers=%d resolved=%d curl=%s" % [
fingers.size(), _fing.size(), _curl])
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)
_close_hands(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
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
var t_pocket_hip := _tv("pocket_hip", POCKET_HIP)
var t_pocket_ads := _tv("pocket_ads", POCKET_ADS)
# ~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(_t("pitch_hip", 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 = t_pocket_hip.lerp(t_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
# Slide the support hand back down the handguard until the arm can
# reach it — but NEVER past this floor. Without one it collapsed to
# 0.083 m on Taila, which puts the support fist on top of the trigger
# fist: that reads as a two-handed pistol grip, not a rifle. A support
# arm that is nearly straight looks far better than no handguard hold.
var floor_fore: float = gun_fore * 0.55
for _i in 6:
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 = maxf(fore_dist * 0.8, floor_fore)
if fore_dist <= floor_fore:
break
var fore_pos := grip_pos + aim_dir * fore_dist
dbg_grip = grip_pos
dbg_fore = fore_pos
dbg_stock = stock_pos
# 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 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized()
var pole_l: Vector3 = _tv("pole_l", 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 st=%s tgtL=%.2f ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [
state, hold_l_target, 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 + _t("trigger_roll",
R_HAND_TWIST)) * arc
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
# 6. Support hand: WRAP the handguard.
#
# Built as a frame, not as an arc plus a twist. A hand gripping a
# cylinder has its fingers curling AROUND that cylinder, which fixes two
# things at once and leaves nothing free:
#
# curl axis must lie along the BARREL, or the fingers close across
# the handguard instead of around it
# palm must face the barrel — up, for a hand supporting from
# underneath
#
# The old version aligned the hand's forearm line to the barrel with a
# shortest arc and then added a constant 0.5 rad twist. A shortest arc
# says nothing about roll, so the roll came entirely from that constant,
# and a constant is only ever right for the one rig it was tuned on — the
# support hand came out upside down.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0 \
and _hand_frame.has("L"):
var hand_l: int = _idx.get("DEF-hand.L", -1)
if hand_l >= 0:
# -aim_dir so the hand comes at the handguard from the body side
# rather than reaching over it backwards.
# `support_roll` rolls the whole target frame about the barrel,
# which is the one axis a hand wrapping a cylinder is free in.
var roll_q := Quaternion(aim_dir, _t("support_roll", 0.0))
var up_r := roll_q * gun_up
var want := Basis(up_r.cross(-aim_dir).normalized(), up_r,
-aim_dir)
var rest: Basis = _hand_frame["L"]
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
_set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l)
# How far each segment of a finger closes, knuckle -> tip, in radians.
#
# Two different grips. The SUPPORT hand wraps a handguard, so all four
# fingers close hard and evenly. The TRIGGER hand wraps a pistol grip with
# three fingers while the index lies along the trigger, nearly straight —
# curling it with the rest is the single thing that most makes a game
# character look like they are squeezing a bar of soap rather than holding a
# rifle.
const CURL_WRAP := [0.85, 1.15, 0.85]
const CURL_TRIGGER := [0.42, 0.55, 0.35]
# The thumb opposes rather than curls, so it closes less and it is the one
# digit whose middle joint barely bends.
const CURL_THUMB := [0.45, 0.30, 0.25]
const DIGITS := ["index", "middle", "ring", "pinky"]
## Close both hands around whatever the IK put them on.
##
## Applied AFTER the hold, so the fingers follow wherever the wrists ended up.
## Weighted by the per-arm hold blend, so a hand the clip has taken back opens
## again instead of staying clamped shut.
func _close_hands(skel: Skeleton3D) -> void:
_close_hand(skel, "R", _hold_r)
_close_hand(skel, "L", _hold_l)
func _close_hand(skel: Skeleton3D, side: String, w: float) -> void:
if w <= 0.01 or not _curl.has(side):
return
var axis: Vector3 = _curl[side]
for digit in DIGITS:
var bones: PackedInt32Array = _fing.get(digit + "." + side,
PackedInt32Array())
if bones.is_empty():
continue
# The right index rides the trigger; everything else wraps.
var trigger: bool = side == "R" and digit == "index"
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
for i in bones.size():
var a: float = amount[i] if i < amount.size() else amount[-1]
_add_space(skel, bones[i], Quaternion(axis, a * scale * w))
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
for i in thumb.size():
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
_add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w))
## 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)