Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 13:42:37 +01:00
1 parent 68f49253fe
commit cd285b3a39
12 files changed
+242 -74

No files matched your search

+70 -37
View File
@@ -418,33 +418,36 @@ func _build_skinned_body(npc_type: int) -> bool:
_head.name = "Head"
_head.position = _rest_origin(["Head"])
_rig.add_child(_head)
for side in [-1.0, 1.0]:
var suffix := "L" if side < 0.0 else "R"
# The Quaternius rig labels its joints with the opposite side (its "L" sits
# at +x while the project's "L" sits at −x), so the dot-suffixed names are
# looked up with a swapped suffix.
var qsuffix := "R" if side < 0.0 else "L"
for i in 2:
var suffix := "L" if i == 0 else "R"
# The proxy pivots sit on the resolved rig bones (see RIG_BONES), so the
# proxy chirality and the exported joint naming never have to agree.
var hip_bone := bone_name("hip", i)
var knee_bone := bone_name("knee", i)
var shoulder_bone := bone_name("shoulder", i)
var elbow_bone := bone_name("elbow", i)
var hand_bone := bone_name("hand", i)
var hip := Node3D.new()
hip.name = "Hip" + suffix
hip.position = _rest_origin(["Hip" + suffix, "UpperLeg." + qsuffix])
hip.position = _rest_origin([hip_bone])
_rig.add_child(hip)
var knee := Node3D.new()
knee.name = "Knee"
knee.position = _rest_origin(["Knee" + suffix, "LowerLeg." + qsuffix]) - hip.position
knee.position = _rest_origin([knee_bone]) - hip.position
hip.add_child(knee)
_legs.append(hip)
_knees.append(knee)
var shoulder := Node3D.new()
shoulder.name = "Shoulder" + suffix
shoulder.position = _rest_origin(["Shoulder" + suffix, "UpperArm." + qsuffix])
shoulder.position = _rest_origin([shoulder_bone])
_rig.add_child(shoulder)
var elbow := Node3D.new()
elbow.name = "Elbow"
elbow.position = _rest_origin(["Elbow" + suffix, "LowerArm." + qsuffix]) - shoulder.position
elbow.position = _rest_origin([elbow_bone]) - shoulder.position
shoulder.add_child(elbow)
var hand := Node3D.new()
hand.name = "Hand"
hand.position = _rest_origin(["Hand" + suffix, "Wrist." + qsuffix]) - _rest_origin(["Elbow" + suffix, "LowerArm." + qsuffix])
hand.position = _rest_origin([hand_bone]) - _rest_origin([elbow_bone])
elbow.add_child(hand)
_arms.append(shoulder)
_elbows.append(elbow)
@@ -509,44 +512,74 @@ func _rest_origin(names: Array) -> Vector3:
return _skeleton.get_bone_global_rest(_bone_indices[bone_name]).origin
return Vector3.ZERO
## Bone-name candidates per animation joint, in proxy order: index 0 is the
## avatar's left (-x) and index 1 its right (+x). The Quaternius rig labels its
## dot-suffixed joints with the opposite side (its `L` sits at +x), so the pairs
## carry the rig-specific mapping and nothing else has to know about it.
const RIG_BONES := {
"head": [["Head"], ["Head"]],
"hip": [["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"]],
"knee": [["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"]],
"shoulder": [["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"]],
"elbow": [["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"]],
"hand": [["Wrist.R", "HandL"], ["Wrist.L", "HandR"]],
}
## Resolved skeleton bone name for an animation joint on one side. Returns an
## empty string when the rig has neither candidate, so both the Quaternius
## 62-joint rig and the 13-core fallback resolve through the same call.
func bone_name(joint: String, side: int = 0) -> String:
if not RIG_BONES.has(joint):
return ""
var pair: Array = RIG_BONES[joint]
return _bone_of(pair[0], pair[1], side)
## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so
## an X rotation swings them forward/back; the Head bone points up, so a Y
## rotation is the look yaw. The Quaternius rig uses dot-suffixed names
## (UpperLeg.L, UpperArm.L, ...); the old 13-core rig uses HipL/ShoulderL, so
## both are tried and the first one present in the skeleton wins.
## rotation is the look yaw.
func _sync_skeleton() -> void:
if _skeleton == null or _bone_indices.is_empty():
return
for i in 2:
_pose_bone_any(["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"], i, _legs[i].rotation.x)
for i in 2:
_pose_bone_any(["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"], i, _knees[i].rotation.x)
for i in 2:
_pose_bone_any(["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"], i, _arms[i].rotation.x, Vector3.LEFT)
for i in 2:
_pose_bone_any(["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"], i, _elbows[i].rotation.x, Vector3.LEFT)
var hip_swing := Quaternion(Vector3.RIGHT, _legs[i].rotation.x)
_swing_bone(bone_name("hip", i), hip_swing)
# `_swing_bone` writes the absolute global rotation, so a child bone has to
# receive the swing it inherits from its parent as well as its own.
_swing_bone(bone_name("knee", i), hip_swing * Quaternion(Vector3.RIGHT, _knees[i].rotation.x))
var shoulder_swing := Quaternion(Vector3.LEFT, _arms[i].rotation.x)
_swing_bone(bone_name("shoulder", i), shoulder_swing)
_swing_bone(bone_name("elbow", i), shoulder_swing * Quaternion(Vector3.LEFT, _elbows[i].rotation.x))
if _head != null:
_pose_bone("Head", _head.rotation.y, Vector3.UP)
var head := bone_name("head")
if head != "":
_swing_bone(head, Quaternion(Vector3.UP, _head.rotation.y))
func _pose_bone_any(left: Array, right: Array, index: int, angle: float, axis: Vector3 = Vector3.RIGHT) -> void:
var names: Array = left if index == 0 else right
for bone_name in names:
## First bone present from the left list (index 0) or the right list (index 1).
func _bone_of(left: Array, right: Array, index: int) -> String:
for bone_name in (left if index == 0 else right):
if _bone_indices.has(bone_name):
_pose_bone(bone_name, angle, axis)
return
return str(bone_name)
return ""
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void:
if not _bone_indices.has(bone_name):
## Swing a bone about a world axis through its head, keeping its rest orientation
## as the neutral. Writing the *global* pose is rig-agnostic: it does not depend
## on how the armature pack exported its rest rolls or on the pose/rest split the
## glTF importer chose, both of which differ between the 62-joint Quaternius rig
## and the 13-core fallback. Measuring `get_bone_global_pose()` after the call is
## the only reliable check (see the pose-capture gate).
##
## `rest` must be the **global** rest. `get_bone_rest()` is parent-relative and
## already carries the importer's axis conversion, so conjugating with it swung
## the limbs about the wrong axis and splayed the arms.
func _swing_bone(bone_name: String, swing: Quaternion) -> void:
if bone_name == "":
return
var index: int = _bone_indices[bone_name]
# The pose is rest-relative (global = parent * rest * pose), so the proxy's
# world-space swing axis must be expressed in the bone's rest-local frame.
# `rest * Quaternion(rest.inverse() * axis, angle)` swings around the world
# `axis`; for the old rig (rest = 180 deg) this reduces to the legacy
# `rest * Quaternion(axis, angle)`, so the 13-core fallback is unchanged.
var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
var local_axis := rest.inverse() * axis
_skeleton.set_bone_pose_rotation(index, rest * Quaternion(local_axis, angle))
_skeleton.reset_bone_pose(index)
var rest := _skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion()
var current := _skeleton.get_bone_global_pose(index)
var target := Basis((swing * rest).normalized()).scaled(current.basis.get_scale())
_skeleton.set_bone_global_pose(index, Transform3D(target, current.origin))
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()