Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/design/RETARGETING_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions libs/motionRetarget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand 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 |
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions libs/motionRetarget/include/motionRetarget/PoseRetargeter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions libs/motionRetarget/include/motionRetarget/RestPose.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::optional<pxr::GfQuatf>> 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.
//
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion libs/motionRetarget/src/PoseRetargeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
}

Expand Down
41 changes: 39 additions & 2 deletions libs/motionRetarget/src/RestPose.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::size_t>(jointIndex) >= skeleton.GetSize())
{
return Identity();
}
const auto slot = static_cast<std::size_t>(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<std::size_t>(cursor) < skeleton.GetSize();
++depth)
{
world = GetLocalRestRotation(skeleton, cursor) * world;
cursor = skeleton.GetJoints()[static_cast<std::size_t>(cursor)].parent;
}
return world.GetNormalized();
}

RestPoseCorrection::RestPoseCorrection()
{
pre.fill(Identity());
Expand Down Expand Up @@ -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<SkeletonJoint>& joints = target.GetJoints();
Expand All @@ -138,8 +175,8 @@ ComputeRestPoseCorrection(const SourceRestPose& source, const SkeletonDescriptor
: Identity();

const SkeletonJoint& joint = joints[static_cast<std::size_t>(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))
Expand Down
70 changes: 70 additions & 0 deletions libs/motionRetarget/tests/test_motion_retarget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::size_t>(J::LeftShoulder)] = shoulderAim;
sourceRest.localRotations[static_cast<std::size_t>(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<std::size_t>(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()
{
Expand Down Expand Up @@ -1321,6 +1390,7 @@ main()
TestIdentityRestPosesPassRotationsThrough();
TestRestPoseCorrectionPreservesTheWorldDelta();
TestRestPoseCorrectionAccountsForTheWholeAncestorChain();
TestTargetReferenceRestIsSeparateFromUsdSkelRest();
TestRootMotionModes();
TestDesignTripletHandOff();
TestUnmappedJointsStayAtRestAndAreReported();
Expand Down
2 changes: 1 addition & 1 deletion libs/motionSource/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions libs/motionSource/include/motionSource/CanonicalConversion.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions libs/motionSource/src/CanonicalConversion.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
{
Expand Down
15 changes: 15 additions & 0 deletions libs/motionSource/tests/test_canonical_conversion.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1233,6 +1247,7 @@ main()
TestInvalidAnimationIsRefused();
TestQuaternionTrackIsRefusedWithAReason();
TestRefusalNamesAreComplete();
TestPublicTPoseAimVocabulary();
std::printf("motionSource conversion: verified\n");
return 0;
}
Loading