diff --git a/docs/design/RETARGETING_POLICY.md b/docs/design/RETARGETING_POLICY.md index c7c7111..f15e426 100644 --- a/docs/design/RETARGETING_POLICY.md +++ b/docs/design/RETARGETING_POLICY.md @@ -136,6 +136,16 @@ MMD rig — states its rest explicitly; a rest derived by a second traversal of the source can disagree with the first and shows as a constant per-joint offset that looks like a bad capture. +The target can separately state a humanoid reference rest through +`RetargetOptions::targetRest`. Its optional local rotations follow the target +skeleton's joint order, and unspecified joints use their UsdSkel rest. `T` and +`Tp` in the correction above come from this reference rest. The +`SkeletonDescriptor` still describes the stage's actual rest transforms, and +an undriven joint still takes its rotation from that descriptor. With no +target reference rest, correction is unchanged. A format adapter selects the +bones it aims; `motionSource::TPoseDirection` and `ShortestRotation` provide +the canonical directions and swing without imposing a whole-body aim. + ## 6. Root motion Root motion stays separate from the body (MOTION_CONTRACT §5.3), so where it diff --git a/libs/motionRetarget/README.md b/libs/motionRetarget/README.md index ff01997..33b5c2d 100644 --- a/libs/motionRetarget/README.md +++ b/libs/motionRetarget/README.md @@ -15,7 +15,7 @@ for the edges, enforced by [`tests/check_boundaries.py`](tests/check_boundaries. ## It never opens a stage The target rig arrives as plain values: a `SkeletonDescriptor`, a -`RetargetMap` and a `SourceRestPose`, not a `UsdSkelSkeleton`. Reading them off +`RetargetMap`, a `SourceRestPose`, and optionally a `TargetRestPose`, not a `UsdSkelSkeleton`. Reading them off a stage is the caller's job. A format repository does it from its own binding, and `motionUsd` does it from a skeleton. That keeps the retarget testable without USD composition, and usable by a live source that has no stage at all. @@ -26,7 +26,7 @@ without USD composition, and usable by a live source that has no stage at all. | --- | --- | | `motionRetarget/SkeletonDescriptor.h` | `SkeletonJoint`, `SkeletonDescriptor`: joint tokens, parents derived from `a/b/c` joint paths, decomposed rest transforms with their scale, and `DecomposeRestTransform` | | `motionRetarget/RetargetMap.h` | `RetargetMap`: human bone → target joint index, and duplicate-binding reporting | -| `motionRetarget/RestPose.h` | `SourceRestPose`, `RestPoseCorrection`, `ComputeRestPoseCorrection` | +| `motionRetarget/RestPose.h` | `SourceRestPose`, optional `TargetRestPose`, `RestPoseCorrection`, `ComputeRestPoseCorrection` | | `motionRetarget/RootMotionPolicy.h` | `RootMotionMode` (`Ignore` / `Hips` / `RootJoint`), `RootMotionOptions`, `ResolveRootTranslation` | | `motionRetarget/PoseRetargeter.h` | `PoseRetargeter`, `RetargetedPose`, `RetargetedAnimation`, `JointLocalTransforms` (one retargeted sample in a `UsdSkelAnimation`'s shape, scales included), `GetJointWorldTransform`, `DiagnoseRig` | | `motionRetarget/Diagnostics.h` | the eight `MOTION_RETARGET_*` codes (`RetargetDiagnosticCode`) and their table, `RetargetDiagnostic`, `RetargetDiagnostics`. The library raises five, and only a caller holding a stage can raise the other three | @@ -55,6 +55,14 @@ without USD composition, and usable by a live source that has no stage at all. Unmapped joints keep their rest transform, so a clip that drives only part of a rig leaves the rest of it alone instead of collapsing it to identity. +For a rig whose humanoid reference rest differs from its UsdSkel rest, fill +`RetargetOptions::targetRest.localRotations` in the target skeleton's joint +order. Each optional rotation is local to that joint's parent; absent entries +use `SkeletonJoint::restRotation`. The reference affects correction of driven +bones only. An undriven bone still receives its UsdSkel rest rotation. The +format adapter decides which joints need an aimed reference, so an MMD adapter +can select only its arm chain. + ## Building It builds as part of the workspace root `CMakeLists.txt`. Standalone: diff --git a/libs/motionRetarget/include/motionRetarget/PoseRetargeter.h b/libs/motionRetarget/include/motionRetarget/PoseRetargeter.h index 812bef2..060acfe 100644 --- a/libs/motionRetarget/include/motionRetarget/PoseRetargeter.h +++ b/libs/motionRetarget/include/motionRetarget/PoseRetargeter.h @@ -121,6 +121,9 @@ MOTIONRETARGET_API bool GetJointWorldTransform(const SkeletonDescriptor& skeleto struct RetargetOptions { RootMotionOptions rootMotion; + // Reference orientation used only to correct driven rotations. Unset + // joints, and joints a clip does not drive, retain the UsdSkel rest. + TargetRestPose targetRest; // The bones this target requires, in the order the caller wants them // reported. Empty -- the default -- requires nothing: the joint vocabulary diff --git a/libs/motionRetarget/include/motionRetarget/RestPose.h b/libs/motionRetarget/include/motionRetarget/RestPose.h index 76157bc..6342e92 100644 --- a/libs/motionRetarget/include/motionRetarget/RestPose.h +++ b/libs/motionRetarget/include/motionRetarget/RestPose.h @@ -95,6 +95,22 @@ struct SourceRestPoseResult // an exec bundle; this is where the two meet. MOTIONRETARGET_API SourceRestPoseResult BuildSourceRestPose(const SkeletonDescriptor& semanticSkeleton); +// Optional humanoid reference rest for a target rig. Slots use the target's +// joint order; an unset slot uses SkeletonJoint::restRotation. This stays +// separate from the UsdSkel rest, which is also the pose of an undriven joint. +// Stating local rotations lets an override on an ancestor contribute to every +// descendant's reference world rotation without changing the skeleton. +struct TargetRestPose +{ + std::vector> localRotations; + + // An unset slot uses the skeleton rest; an invalid index returns identity. + MOTIONRETARGET_API pxr::GfQuatf GetLocalRestRotation(const SkeletonDescriptor& skeleton, + int jointIndex) const; + MOTIONRETARGET_API pxr::GfQuatf GetWorldRestRotation(const SkeletonDescriptor& skeleton, + int jointIndex) const; +}; + // Per-bone correction carrying a rest-relative rotation from the source rig // onto a target whose rest pose differs. // @@ -141,5 +157,9 @@ MOTIONRETARGET_API bool operator!=(const RestPoseCorrection& a, const RestPoseCo MOTIONRETARGET_API RestPoseCorrection ComputeRestPoseCorrection(const SourceRestPose& source, const SkeletonDescriptor& target, const RetargetMap& map); +MOTIONRETARGET_API RestPoseCorrection ComputeRestPoseCorrection(const SourceRestPose& source, + const SkeletonDescriptor& target, + const RetargetMap& map, + const TargetRestPose& targetRest); } // namespace openstrata::motion diff --git a/libs/motionRetarget/src/PoseRetargeter.cpp b/libs/motionRetarget/src/PoseRetargeter.cpp index 23646e5..e08a079 100644 --- a/libs/motionRetarget/src/PoseRetargeter.cpp +++ b/libs/motionRetarget/src/PoseRetargeter.cpp @@ -136,7 +136,7 @@ PoseRetargeter::PoseRetargeter(SkeletonDescriptor skeleton, RetargetMap map, Sou RetargetOptions options) : _skeleton(std::move(skeleton)), _map(std::move(map)), _sourceRest(std::move(sourceRest)), _options(std::move(options)), - _correction(ComputeRestPoseCorrection(_sourceRest, _skeleton, _map)) + _correction(ComputeRestPoseCorrection(_sourceRest, _skeleton, _map, _options.targetRest)) { } diff --git a/libs/motionRetarget/src/RestPose.cpp b/libs/motionRetarget/src/RestPose.cpp index 8487d1c..2743032 100644 --- a/libs/motionRetarget/src/RestPose.cpp +++ b/libs/motionRetarget/src/RestPose.cpp @@ -76,6 +76,36 @@ SourceRestPose::GetWorldRestRotation(openstrata::motion::HumanJoint bone) const return world.GetNormalized(); } +pxr::GfQuatf +TargetRestPose::GetLocalRestRotation(const SkeletonDescriptor& skeleton, int jointIndex) const +{ + if (jointIndex < 0 || static_cast(jointIndex) >= skeleton.GetSize()) + { + return Identity(); + } + const auto slot = static_cast(jointIndex); + if (slot < localRotations.size() && localRotations[slot]) + { + return localRotations[slot]->GetNormalized(); + } + return skeleton.GetJoints()[slot].restRotation.GetNormalized(); +} + +pxr::GfQuatf +TargetRestPose::GetWorldRestRotation(const SkeletonDescriptor& skeleton, int jointIndex) const +{ + pxr::GfQuatf world = Identity(); + int cursor = jointIndex; + for (std::size_t depth = 0; + depth < skeleton.GetSize() && cursor >= 0 && static_cast(cursor) < skeleton.GetSize(); + ++depth) + { + world = GetLocalRestRotation(skeleton, cursor) * world; + cursor = skeleton.GetJoints()[static_cast(cursor)].parent; + } + return world.GetNormalized(); +} + RestPoseCorrection::RestPoseCorrection() { pre.fill(Identity()); @@ -113,6 +143,13 @@ operator!=(const RestPoseCorrection& a, const RestPoseCorrection& b) noexcept RestPoseCorrection ComputeRestPoseCorrection(const SourceRestPose& source, const SkeletonDescriptor& target, const RetargetMap& map) +{ + return ComputeRestPoseCorrection(source, target, map, TargetRestPose()); +} + +RestPoseCorrection +ComputeRestPoseCorrection(const SourceRestPose& source, const SkeletonDescriptor& target, + const RetargetMap& map, const TargetRestPose& targetReference) { RestPoseCorrection correction; const std::vector& joints = target.GetJoints(); @@ -138,8 +175,8 @@ ComputeRestPoseCorrection(const SourceRestPose& source, const SkeletonDescriptor : Identity(); const SkeletonJoint& joint = joints[static_cast(jointIndex)]; - const pxr::GfQuatf targetRest = joint.restRotation.GetNormalized(); - const pxr::GfQuatf targetParentRest = target.GetWorldRestRotation(joint.parent); + const pxr::GfQuatf targetRest = targetReference.GetLocalRestRotation(target, jointIndex); + const pxr::GfQuatf targetParentRest = targetReference.GetWorldRestRotation(target, joint.parent); if (IsIdentityRotation(sourceRest) && IsIdentityRotation(sourceParentRest) && IsIdentityRotation(targetRest) && IsIdentityRotation(targetParentRest)) diff --git a/libs/motionRetarget/tests/test_motion_retarget.cpp b/libs/motionRetarget/tests/test_motion_retarget.cpp index 9a1f41b..9ea37eb 100644 --- a/libs/motionRetarget/tests/test_motion_retarget.cpp +++ b/libs/motionRetarget/tests/test_motion_retarget.cpp @@ -695,6 +695,75 @@ TestRestPoseCorrectionAccountsForTheWholeAncestorChain() targetChestRest)); } +void +TestTargetReferenceRestIsSeparateFromUsdSkelRest() +{ + using J = openstrata::motion::HumanJoint; + const pxr::GfQuatf armAim = Rotation(kAxisZ, 40.0f); + const pxr::GfQuatf shoulderAim = Rotation(kAxisY, 15.0f); + const pxr::GfQuatf identity(1.0f, pxr::GfVec3f(0.0f)); + + openstrata::motion::SkeletonDescriptor skeleton; + openstrata::motion::SkeletonJoint shoulder; + shoulder.token = "Shoulder"; + skeleton.AddJoint(shoulder); + openstrata::motion::SkeletonJoint arm; + arm.token = "Shoulder/Arm"; + skeleton.AddJoint(arm); + skeleton.ResolveParentsFromTokens(); + + openstrata::motion::RetargetMap map; + assert(map.SetJointIndex(J::LeftShoulder, 0, skeleton.GetSize())); + assert(map.SetJointIndex(J::LeftUpperArm, 1, skeleton.GetSize())); + + openstrata::motion::TargetRestPose targetRest; + targetRest.localRotations.resize(skeleton.GetSize()); + targetRest.localRotations[0] = shoulderAim; + targetRest.localRotations[1] = armAim; + assert(SameOrientation(targetRest.GetWorldRestRotation(skeleton, 1), shoulderAim * armAim)); + assert(SameOrientation(skeleton.GetWorldRestRotation(1), identity)); + + openstrata::motion::SourceRestPose sourceRest; + sourceRest.SetParent(J::LeftUpperArm, J::LeftShoulder); + const auto correction = openstrata::motion::ComputeRestPoseCorrection( + sourceRest, skeleton, map, targetRest); + assert(SameOrientation(correction.Apply(J::LeftShoulder, identity), shoulderAim)); + assert(SameOrientation(correction.Apply(J::LeftUpperArm, identity), armAim)); + const pxr::GfQuatf animated = Rotation(kAxisX, 25.0f); + const pxr::GfQuatf corrected = correction.Apply(J::LeftUpperArm, animated); + const pxr::GfQuatf targetDelta = + (shoulderAim * corrected) * (shoulderAim * armAim).GetInverse(); + assert(SameOrientation(targetDelta, animated)); + + // A source stating the same reference rest as the target needs no offset. + sourceRest.localRotations[static_cast(J::LeftShoulder)] = shoulderAim; + sourceRest.localRotations[static_cast(J::LeftUpperArm)] = armAim; + const auto sameRest = openstrata::motion::ComputeRestPoseCorrection( + sourceRest, skeleton, map, targetRest); + assert(SameOrientation(sameRest.Apply(J::LeftUpperArm, armAim), armAim)); + + openstrata::motion::RetargetOptions options; + options.targetRest = targetRest; + const openstrata::motion::PoseRetargeter retargeter(skeleton, map, + openstrata::motion::SourceRestPose(), options); + openstrata::motion::MotionPose pose; + pose.validRotations.set(static_cast(J::LeftUpperArm)); + const auto driven = retargeter.Retarget(pose); + assert(SameOrientation(driven.rotations[1], armAim)); + assert(SameOrientation(driven.rotations[0], identity)); + + pose.validRotations.reset(); + const auto undriven = retargeter.Retarget(pose); + assert(SameOrientation(undriven.rotations[0], identity)); + assert(SameOrientation(undriven.rotations[1], identity)); + + // An empty reference rest preserves the existing correction exactly. + assert(openstrata::motion::ComputeRestPoseCorrection( + sourceRest, skeleton, map) == + openstrata::motion::ComputeRestPoseCorrection( + sourceRest, skeleton, map, openstrata::motion::TargetRestPose())); +} + void TestRootMotionModes() { @@ -1321,6 +1390,7 @@ main() TestIdentityRestPosesPassRotationsThrough(); TestRestPoseCorrectionPreservesTheWorldDelta(); TestRestPoseCorrectionAccountsForTheWholeAncestorChain(); + TestTargetReferenceRestIsSeparateFromUsdSkelRest(); TestRootMotionModes(); TestDesignTripletHandOff(); TestUnmappedJointsStayAtRestAndAreReported(); diff --git a/libs/motionSource/README.md b/libs/motionSource/README.md index bcd1d6d..a6e791f 100644 --- a/libs/motionSource/README.md +++ b/libs/motionSource/README.md @@ -27,7 +27,7 @@ The value model and its invariants, and the profile contract over them: | `SourceProfile.h` | what one producer's export means: the stated vocabulary, `SourceProfile`, `ValidateSourceProfile`, `MatchSourceProfile` and its typed refusals | | `SourceProfileFile.h` | a profile as a file: the keys, the small language they are written in, and the line a file that gets one wrong is told about | | `CanonicalMetadata.h` | provenance's crossing into `motionCore` | -| `CanonicalConversion.h` | the converter: the change of basis, the angle composition, the path rule, the rest pose, the root policies, and the four ways a conversion refuses | +| `CanonicalConversion.h` | the converter: the change of basis, the angle composition, the path rule, the rest pose, the root policies, the four ways a conversion refuses, and public `TPoseDirection` / `ShortestRotation` aim helpers | The profiles themselves are **data**, in [`profiles/motion/`](../../profiles/motion/) rather than here: a product name may appear in one precisely because no code in diff --git a/libs/motionSource/include/motionSource/CanonicalConversion.h b/libs/motionSource/include/motionSource/CanonicalConversion.h index dceeae5..643c850 100644 --- a/libs/motionSource/include/motionSource/CanonicalConversion.h +++ b/libs/motionSource/include/motionSource/CanonicalConversion.h @@ -212,12 +212,22 @@ MOTIONSOURCE_API SourceQuat ComposeSourceRotation(const SourceEulerAngles& angle SourceEulerOrder order, SourceAngleUnit unit) noexcept; +// Canonical T-pose direction of a bone's outgoing segment (+Y up, +Z +// forward, +X character-left). Returns zero where the vocabulary specifies +// no direction. Format adapters choose which bones to aim. +MOTIONSOURCE_API pxr::GfVec3f TPoseDirection(openstrata::motion::HumanJoint bone) noexcept; + +// Shortest swing between two nonzero unit directions. The caller owns the +// segment geometry and any roll; this helper contributes only the aim. +MOTIONSOURCE_API pxr::GfQuatf ShortestRotation(const pxr::GfVec3f& from, + const pxr::GfVec3f& to) noexcept; + // The clip's own rest pose, per canonical bone, in canonical basis and metres. // // No parent array: the semantic parent of a bone within a rig carrying -// `present` is `openstrata::motion::NearestPresentAncestor`, and a second copy of the -// humanoid taxonomy is a defect waiting to happen. A bone `present` does not -// carry has no rest and its entries are left at identity and zero. +// `present` is `openstrata::motion::NearestPresentAncestor`, and a second copy +// of the humanoid taxonomy is a defect waiting to happen. A bone not present +// has no rest; its entries are left at identity and zero. struct CanonicalRestPose { MOTIONSOURCE_API CanonicalRestPose(); diff --git a/libs/motionSource/src/CanonicalConversion.cpp b/libs/motionSource/src/CanonicalConversion.cpp index f2fc236..0b3aa7a 100644 --- a/libs/motionSource/src/CanonicalConversion.cpp +++ b/libs/motionSource/src/CanonicalConversion.cpp @@ -169,7 +169,7 @@ PathFromNearestBoundAncestor(const SourceSkeleton& skeleton, std::size_t jointIn // contributes identity, so a rig carrying fingers or a jaw is not refused; it // simply gets no T-pose opinion about them. pxr::GfVec3f -TPoseDirection(openstrata::motion::HumanJoint bone) noexcept +TPoseDirectionImpl(openstrata::motion::HumanJoint bone) noexcept { const pxr::GfVec3f up(0.0f, 1.0f, 0.0f); const pxr::GfVec3f left(1.0f, 0.0f, 0.0f); @@ -223,7 +223,7 @@ TPoseDirection(openstrata::motion::HumanJoint bone) noexcept // exactly the freedom a T-pose does not pin, and which would show up as a // forearm or shin rotated about itself while every joint position stayed right. pxr::GfQuatf -ShortestRotation(const pxr::GfVec3f& from, const pxr::GfVec3f& to) noexcept +ShortestRotationImpl(const pxr::GfVec3f& from, const pxr::GfVec3f& to) noexcept { const float dot = pxr::GfDot(from, to); if (dot > 0.999999f) @@ -259,6 +259,18 @@ Refuse(SourceConversion result, ConversionRefusal refusal, std::string detail) } // namespace +pxr::GfVec3f +TPoseDirection(openstrata::motion::HumanJoint bone) noexcept +{ + return TPoseDirectionImpl(bone); +} + +pxr::GfQuatf +ShortestRotation(const pxr::GfVec3f& from, const pxr::GfVec3f& to) noexcept +{ + return ShortestRotationImpl(from, to); +} + std::string_view ConversionRefusalName(ConversionRefusal refusal) noexcept { diff --git a/libs/motionSource/tests/test_canonical_conversion.cpp b/libs/motionSource/tests/test_canonical_conversion.cpp index 6b1b468..f5939fe 100644 --- a/libs/motionSource/tests/test_canonical_conversion.cpp +++ b/libs/motionSource/tests/test_canonical_conversion.cpp @@ -1195,6 +1195,20 @@ TestRefusalNamesAreComplete() } } +void +TestPublicTPoseAimVocabulary() +{ + using J = openstrata::motion::HumanJoint; + const pxr::GfVec3f left(1.0f, 0.0f, 0.0f); + assert(openstrata::motion::TPoseDirection(J::LeftUpperArm) == left); + assert(openstrata::motion::TPoseDirection(J::RightUpperArm) == -left); + assert(openstrata::motion::TPoseDirection(J::LeftIndexProximal) == pxr::GfVec3f(0.0f)); + const pxr::GfVec3f diagonal(0.70710678f, -0.70710678f, 0.0f); + const pxr::GfQuatf aim = openstrata::motion::ShortestRotation( + diagonal, openstrata::motion::TPoseDirection(J::LeftUpperArm)); + assert((aim.Transform(diagonal) - left).GetLength() < 1e-5f); +} + } // namespace int @@ -1233,6 +1247,7 @@ main() TestInvalidAnimationIsRefused(); TestQuaternionTrackIsRefusedWithAReason(); TestRefusalNamesAreComplete(); + TestPublicTPoseAimVocabulary(); std::printf("motionSource conversion: verified\n"); return 0; }