Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
This commit is contained in:
1 parent
68f49253fe
commit
cd285b3a39
12 files changed
+242
-74
No files matched your search
+70
-37
@@ -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()
|
||||
|
||||
@@ -9,6 +9,17 @@ const NPC_BASE := "res://assets/models/generated/npc_base.glb"
|
||||
const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"]
|
||||
const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
||||
"ElbowL", "ElbowR", "HandL", "HandR"]
|
||||
## The Quaternius rig (ADR-0018) is a 62-joint armature. `NpcAvatar` maps these
|
||||
## joints onto the animation proxies, so they are the drop-in requirement for a
|
||||
## Quaternius-derived character instead of the 13-core names.
|
||||
const QUATERNIUS_BONES := ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L",
|
||||
"LowerLeg.R", "UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
|
||||
"Wrist.L", "Wrist.R"]
|
||||
|
||||
## Quaternius-derived characters are detected by a dot-suffixed UpperArm joint;
|
||||
## the procedural library uses ShoulderL/ShoulderR instead.
|
||||
func _is_quaternius(skeleton: Skeleton3D) -> bool:
|
||||
return skeleton != null and skeleton.find_bone("UpperArm.L") >= 0
|
||||
|
||||
func _manifest() -> Dictionary:
|
||||
if not FileAccess.file_exists(MANIFEST):
|
||||
@@ -76,7 +87,8 @@ func test_characters_match_the_npc_base_contract() -> bool:
|
||||
problems.append("%s lacks Skeleton3D/npc_base" % name)
|
||||
node.free()
|
||||
continue
|
||||
for bone in BONES:
|
||||
var required: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES
|
||||
for bone in required:
|
||||
if skeleton.find_bone(bone) < 0:
|
||||
problems.append("%s missing bone %s" % [name, bone])
|
||||
var slots := {}
|
||||
@@ -105,6 +117,11 @@ func test_character_joints_line_up_with_npc_base() -> bool:
|
||||
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null
|
||||
if skeleton == null:
|
||||
problems.append("%s has no skeleton" % name)
|
||||
elif _is_quaternius(skeleton):
|
||||
# ADR-0018: a Quaternius character keeps its own 62-joint skeleton and
|
||||
# NpcAvatar re-derives the proxy pivots from it, so the joints are not
|
||||
# expected to sit on npc_base's 13-core positions.
|
||||
pass
|
||||
else:
|
||||
for bone in BONES:
|
||||
var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin
|
||||
@@ -127,10 +144,18 @@ func test_character_bones_accept_the_avatar_pose_math() -> bool:
|
||||
if skeleton == null:
|
||||
problems.append(name)
|
||||
else:
|
||||
for bone in BONES:
|
||||
var bones: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES
|
||||
for bone in bones:
|
||||
var index := skeleton.find_bone(bone)
|
||||
var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
|
||||
skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4))
|
||||
if index < 0:
|
||||
problems.append("%s missing %s" % [name, bone])
|
||||
continue
|
||||
# NpcAvatar._swing_bone(): pose = rest^-1 * swing * rest, applied
|
||||
# through the global pose. The composition must stay a valid
|
||||
# rotation on every limb bone of either rig.
|
||||
var rest := skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion()
|
||||
var swing := Quaternion(Vector3.RIGHT, 0.4)
|
||||
skeleton.set_bone_pose_rotation(index, rest.inverse() * swing * rest)
|
||||
if not skeleton.get_bone_pose_rotation(index).is_normalized():
|
||||
problems.append("%s.%s" % [name, bone])
|
||||
if node:
|
||||
|
||||
@@ -141,8 +141,8 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
||||
"a skinned library character is used")
|
||||
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
||||
"Rig/Head survives the skinned path")
|
||||
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head")
|
||||
and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"),
|
||||
var c := TestUtils.check(avatar._skeleton != null and avatar.bone_name("head") != ""
|
||||
and avatar.bone_name("hip", 0) != "" and avatar.bone_name("hand", 1) != "",
|
||||
"armature bones are bound")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
@@ -152,10 +152,10 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
||||
func test_pose_reaches_the_skeleton_bones() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 55)
|
||||
var sk := avatar._skeleton
|
||||
var hip: int = int(avatar._bone_indices["HipL"])
|
||||
var knee: int = int(avatar._bone_indices["KneeL"])
|
||||
var rest_hip := sk.get_bone_rest(hip).basis.get_rotation_quaternion()
|
||||
var rest_knee := sk.get_bone_rest(knee).basis.get_rotation_quaternion()
|
||||
var hip: int = int(avatar._bone_indices[avatar.bone_name("hip", 0)])
|
||||
var knee: int = int(avatar._bone_indices[avatar.bone_name("knee", 0)])
|
||||
var rest_hip := sk.get_bone_pose_rotation(hip)
|
||||
var rest_knee := sk.get_bone_pose_rotation(knee)
|
||||
avatar.arrived = true
|
||||
avatar.npc.intent = "sit"
|
||||
for _i in 45:
|
||||
@@ -179,6 +179,49 @@ func test_pose_reaches_the_skeleton_bones() -> bool:
|
||||
avatar.free()
|
||||
return a and b and c and d
|
||||
|
||||
## Regression (ADR-0018): the shipped base must resolve every animation joint on
|
||||
## the rig it actually uses, keep the baked arm-down rest, and put each proxy on
|
||||
## the matching body side. The Quaternius rig labels its dot-suffixed joints with
|
||||
## the opposite side (its `L` sits at +x), so `RIG_BONES` is the single mapping and
|
||||
## a silent side swap would splay the arms across the torso.
|
||||
##
|
||||
## The swing itself is proven by the pose-capture gate; `_swing_bone()` writes the
|
||||
## global pose, which Godot only computes for a skeleton inside the scene tree and
|
||||
## the headless runner executes tests before the tree is ready.
|
||||
func test_shipped_rig_resolves_the_animation_joints() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 91)
|
||||
var sk := avatar._skeleton
|
||||
var problems: Array[String] = []
|
||||
for joint in NpcAvatar.RIG_BONES:
|
||||
for side in 2:
|
||||
var bone := avatar.bone_name(joint, side)
|
||||
if bone == "" or not avatar._bone_indices.has(bone):
|
||||
problems.append("%s/%d unresolved" % [joint, side])
|
||||
var a := TestUtils.check(problems.is_empty(),
|
||||
"the shipped rig resolves every animation joint (%s)" % str(problems))
|
||||
var wrist := avatar.bone_name("hand", 0)
|
||||
var shoulder := avatar.bone_name("shoulder", 0)
|
||||
var b := TestUtils.check(wrist != "" and shoulder != "", "shoulder/hand joints resolve")
|
||||
if not b:
|
||||
avatar.free()
|
||||
return false
|
||||
var shoulder_rest := sk.get_bone_global_rest(int(avatar._bone_indices[shoulder]))
|
||||
var wrist_rest := sk.get_bone_global_rest(int(avatar._bone_indices[wrist]))
|
||||
var c := TestUtils.check(shoulder_rest.origin.y - wrist_rest.origin.y > 0.3,
|
||||
"the shipped base is baked arm-down (%.3f below the shoulder)"
|
||||
% (shoulder_rest.origin.y - wrist_rest.origin.y))
|
||||
# A straight-down arm axis is what makes an X swing read as forward/back.
|
||||
var arm_down := shoulder_rest.basis.y.normalized().dot(Vector3.DOWN)
|
||||
var d := TestUtils.check(arm_down > 0.95,
|
||||
"the arm rest axis points down (dot %.3f)" % arm_down)
|
||||
# Proxy side 0 must drive the -x arm and side 1 the +x arm on either rig.
|
||||
var left := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 0)]))
|
||||
var right := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 1)]))
|
||||
var e := TestUtils.check(left.origin.x < right.origin.x,
|
||||
"proxy side 0 drives the -x arm (%.3f vs %.3f)" % [left.origin.x, right.origin.x])
|
||||
avatar.free()
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_character_selection_is_deterministic_and_complete() -> bool:
|
||||
# Seed 5 resolves each role to a fixed pool member; the routine crowd roles
|
||||
# now pick from the crowd-variety pools instead of a single npc_casual.
|
||||
|
||||
Reference in new issue
Block a user