#include "animation/AnimClip.h" #include "animation/PoseInertiaInternal.h" #include "animation/AnimSkeleton.h" #include "animation/MotionDatabase.h" #include "animation/MotionMatcher.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include #include using namespace eve::animation; namespace { void locomotion(AnimClip& clip, float speed) { clip.setDuration(0.8f); clip.setLoop(true); clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); clip.addPositionKey(0, 0.8f, 0.f, 0.f, speed * 0.8f); clip.addPositionKey(1, 0.f, 0.f, 0.1f, -0.2f); clip.addPositionKey(1, 0.4f, 0.f, 0.3f, 0.2f); clip.addPositionKey(1, 0.8f, 0.f, 0.1f, -0.2f); } } // namespace TEST_CASE("animation.motionMatching.candidateIntervalsAreAtomicAndConstrainContinuation") { AnimSkeleton skeleton; skeleton.addBone("root"); skeleton.addBone("foot", 0); AnimClip idle("idle"), walk("walk"); locomotion(idle, 0.f); locomotion(walk, 2.f); MotionDatabase db(&skeleton); db.addFeatureBone(1); db.addClip(&idle); db.addClip(&walk); db.bake(); MotionMatcher matcher(&skeleton, &db); std::vector first{{1, 0.2f, 0.5f, -0.2f, true}}; auto selected = matcher.setCandidateRanges(first); REQUIRE(selected.ok()); REQUIRE(selected.value() > 0); matcher.search(); CHECK(matcher.getMatchedClipIndex() == 1); CHECK(matcher.getMatchedTime() >= 0.19999f); CHECK(matcher.getMatchedTime() <= 0.50001f); for (auto bad : std::vector{ {9, 0.f, 0.5f}, {0, 0.6f, 0.2f}, {0, 0.f, 9.f}, {0, 0.f, 0.1f, std::numeric_limits::quiet_NaN()}}) { auto failed = matcher.setCandidateRanges(std::span(&bad, 1)); CHECK(!failed.ok()); matcher.search(); CHECK(matcher.getMatchedClipIndex() == 1); } auto empty = matcher.setCandidateRanges({}); CHECK(!empty.ok()); std::vector second{{0, 0.f, 0.1f}}; auto changed = matcher.setCandidateRanges(second); REQUIRE(changed.ok()); matcher.search(); CHECK(matcher.getMatchedClipIndex() == 0); CHECK(matcher.getMatchedTime() <= 0.10001f); } TEST_CASE("animation.motionMatching.externalPoseAndTrajectoryInputsAreCopied") { AnimSkeleton skeleton; skeleton.addBone("root"); skeleton.addBone("foot", 0); AnimClip left("left"), right("right"); for (auto* clip : {&left, &right}) { clip->setDuration(1.f); clip->setLoop(true); } left.addPositionKey(1, 0.f, -1.f, 0.f, 0.f); right.addPositionKey(1, 0.f, 1.f, 0.f, 0.f); MotionDatabase db(&skeleton); db.addFeatureBone(1); db.addClip(&left); db.addClip(&right); db.bake(); MotionMatcher matcher(&skeleton, &db); matcher.setPoseWeight(1.f); AnimPose input(2); input.setLocalPosition(1, 1.f, 0.f, 0.f); ASSERT(matcher.setQueryPose(input).ok()); AnimPose wrongSkeleton(1); ASSERT(!matcher.setQueryPose(wrongSkeleton).ok()); input.setLocalPosition(1, -1.f, 0.f, 0.f); matcher.search(); CHECK(matcher.getMatchedClipIndex() == 1); std::array trajectory{}; auto configured = matcher.setTrajectory(trajectory); CHECK(configured.ok()); trajectory[1].yaw = std::numeric_limits::infinity(); auto invalid = matcher.setTrajectory(trajectory); CHECK(!invalid.ok()); auto shortInput = matcher.setTrajectory(std::span(trajectory).first(2)); CHECK(!shortInput.ok()); } TEST_CASE("animation.motionMatching.constantRootTrackReceivesPlanarTravel") { AnimSkeleton skeleton; skeleton.addBone("root"); AnimClip clip("in-place"); clip.setDuration(1.f); clip.addPositionKey(0, 0.f, 0.f, 0.2f, 0.f); clip.applyPlanarRootMotion(0, 0.f, 1.7f); AnimPose pose; clip.sample(0.5f, &pose, &skeleton); CHECK(std::abs(pose.getLocalPositionZ(0) - 0.85f) < 0.0001f); CHECK(std::abs(pose.getLocalPositionY(0) - 0.2f) < 0.0001f); } TEST_CASE("animation.motionMatching.loopFeaturesPreserveForwardTravel") { AnimSkeleton skeleton; skeleton.addBone("root"); skeleton.addBone("foot", 0); AnimClip walk("walk"); locomotion(walk, 1.7f); MotionDatabase database(&skeleton); database.addFeatureBone(1); database.addClip(&walk); database.bake(); for (int i = 0; i < database.getFrameCount(); ++i) { CHECK(std::abs(database.frameAt(i).velZ - 1.7f) < 0.001f); // Constant velocity means every phase has identical trajectory features, // even when a one-second horizon crosses more than one 0.8s cycle. for (int f = 2; f < 8; ++f) CHECK(std::abs(database.frameAt(i).feature[f]) < 0.01f); } } TEST_CASE("animation.motionMatching.continuesCyclesAndChangesSpeedAndFacing") { AnimSkeleton skeleton; skeleton.addBone("root"); skeleton.addBone("foot", 0); AnimClip idle("idle"), walk("walk"), run("run"); locomotion(idle, 0.f); locomotion(walk, 1.7f); locomotion(run, 4.2f); MotionDatabase database(&skeleton); database.addFeatureBone(1); database.addClip(&idle); database.addClip(&walk); database.addClip(&run); database.bake(); MotionMatcher matcher(&skeleton, &database); matcher.setSearchInterval(0.08f); matcher.setBlendTime(0.14f); matcher.setVelocityWeight(2.f); matcher.setPoseWeight(0.15f); for (int clip = 0; clip < 3; ++clip) { const float speed = clip == 0 ? 0.f : (clip == 1 ? 1.7f : 4.2f); for (int direction = 0; direction < 4; ++direction) { const float yaw = direction * 1.57079632679f; matcher.setDesiredYaw(yaw); matcher.setDesiredVelocity(std::sin(yaw) * speed, std::cos(yaw) * speed); matcher.search(); REQUIRE(matcher.getMatchedClipIndex() == clip); float time = matcher.getMatchedTime(); for (int frame = 0; frame < 180; ++frame) { matcher.update(1.f / 60.f); CHECK(matcher.getMatchedClipIndex() == clip); CHECK(std::abs(matcher.getMatchedTime() - time - 1.f / 60.f) < 0.0001f); time = matcher.getMatchedTime(); } } } matcher.setDesiredVelocity(0.f, 0.f); matcher.search(); CHECK(matcher.getMatchedClipIndex() == 0); } TEST_CASE("animation.motionMatching.sparseFeaturesMatchFullPose") { AnimSkeleton skeleton; skeleton.addBone("ancestor"); skeleton.addBone("root", 0); skeleton.addBone("foot", 1); skeleton.addBone("tip", 2); skeleton.addBone("unrelated", 0); skeleton.setBindScale(0, 1.2f, 0.9f, 1.4f); skeleton.setBindRotation(0, 0.f, std::sin(0.15f), 0.f, std::cos(0.15f)); skeleton.setBindPosition(2, 0.2f, 0.4f, -0.3f); skeleton.setBindPosition(3, 0.1f, 0.2f, 0.3f); AnimClip clip("hierarchy"); clip.setDuration(0.6f); clip.setSampleRate(10.f); clip.addPositionKey(0, 0.f, 0.f, 0.f, 0.f); clip.addPositionKey(0, 0.6f, 0.2f, 0.1f, 0.3f); clip.addPositionKey(1, 0.f, 0.f, 0.f, 0.f); clip.addPositionKey(1, 0.6f, 0.3f, 0.2f, 0.9f); clip.addRotationKey(1, 0.f, 0.f, 0.f, 0.f, 1.f); clip.addRotationKey(1, 0.6f, 0.f, std::sin(0.2f), 0.f, std::cos(0.2f)); AnimPose pose; auto world = [&](int bone, float time) { clip.sample(time, &pose, &skeleton); pose.computeWorld(&skeleton); return pose.world(bone); }; auto yaw = [](const TransformTRS& t) { return std::atan2(2.f * t.qw * t.qy, 1.f - 2.f * t.qy * t.qy); }; for (int copies : {1, 16}) for (bool loop : {false, true}) { clip.setLoop(false); const auto start = world(1, 0.f), end = world(1, clip.getDuration()); clip.setLoop(loop); MotionDatabase db(&skeleton); db.setRootBone(1); db.addFeatureBone(3); for (int copy = 0; copy < copies; ++copy) db.addClip(&clip); db.bake(); for (int i = 0; i < db.getFrameCount(); ++i) { const auto& frame = db.frameAt(i); const float time = frame.time; const auto root = world(1, time); const float angle = yaw(root), cs = std::cos(angle), sn = std::sin(angle); auto future = [&](float t) { auto value = world(1, t); const float cycles = loop ? std::floor(t / clip.getDuration()) : 0.f; value.px += cycles * (end.px - start.px); value.pz += cycles * (end.pz - start.pz); return value; }; const auto next = future(time + 0.1f); const float vx = (next.px - root.px) / 0.1f; const float vz = (next.pz - root.pz) / 0.1f; CHECK(std::abs(frame.rootX - root.px) < 0.00001f); CHECK(std::abs(frame.rootZ - root.pz) < 0.00001f); CHECK(std::abs(frame.velX - vx) < 0.00001f); CHECK(std::abs(frame.velZ - vz) < 0.00001f); std::vector expected(13); expected[0] = vx * cs - vz * sn; expected[1] = vx * sn + vz * cs; int offset = 2; for (float horizon : {0.33f, 0.66f, 1.f}) { const auto f = future(time + horizon); const float dx = f.px - root.px, dz = f.pz - root.pz; expected[offset++] = dx * cs - dz * sn; expected[offset++] = dx * sn + dz * cs; } yawToForward(yaw(world(1, time + 1.f)) - angle, expected[8], expected[9]); const auto foot = world(3, time); expected[10] = (foot.px - root.px) * cs - (foot.pz - root.pz) * sn; expected[11] = foot.py; expected[12] = (foot.px - root.px) * sn + (foot.pz - root.pz) * cs; db.normalizeFeature(expected); for (int f = 0; f < 13; ++f) CHECK(std::abs(expected[f] - frame.feature[f]) < 0.001f); } } } TEST_CASE("animation.motionMatching.inertiaPreservesVelocityAndInterruptedPose") { detail::PoseInertia inertia; AnimPose source(1), previous(1), target(1), previousTarget(1), output(1); source.setLocalPosition(0, 2.f, 0.f, 0.f); previous.setLocalPosition(0, 1.97f, 0.f, 0.f); target.setLocalPosition(0, 7.f, 0.f, 0.f); previousTarget.setLocalPosition(0, 6.99f, 0.f, 0.f); inertia.remember(previous, .01f); inertia.begin(source, target, previousTarget, .3f); inertia.apply(target, 0.f, output); CHECK(std::abs(output.getLocalPositionX(0)-2.f)<1e-5f); target.setLocalPosition(0,7.0001f,0.f,0.f); inertia.apply(target,.0001f,output); CHECK(std::abs((output.getLocalPositionX(0)-2.f)/.0001f-3.f)<.02f); inertia.apply(target,.1f,output); AnimPose interrupted(1); interrupted.copyFrom(&output); inertia.remember(source,.1f); target.setLocalPosition(0,-4.f,0.f,0.f); previousTarget.copyFrom(&target); inertia.begin(interrupted,target,previousTarget,.2f); inertia.apply(target,0.f,output); CHECK(std::abs(output.getLocalPositionX(0)-interrupted.getLocalPositionX(0))<1e-5f); inertia.apply(target,.2f,output); CHECK(output.getLocalPositionX(0)==-4.f); } TEST_CASE("animation.motionMatching.inertiaQuaternionHemisphereAndConvergence") { detail::PoseInertia inertia; AnimPose source(1), target(1), previousTarget(1), output(1); source.setLocalRotation(0,0.f,std::sin(.8f),0.f,std::cos(.8f)); target.setLocalRotation(0,0.f,0.f,0.f,-1.f); previousTarget.copyFrom(&target); inertia.begin(source,target,previousTarget,.2f); inertia.apply(target,0.f,output); CHECK(std::abs(std::abs(output.local(0).qy)-std::sin(.8f))<1e-5f); for(int i=0;i<=20;++i) { inertia.apply(target,i*.01f,output); auto q=output.local(0); CHECK(std::abs(q.qx*q.qx+q.qy*q.qy+q.qz*q.qz+q.qw*q.qw-1.f)<1e-5f); } CHECK(std::abs(output.local(0).qy)<1e-5f); inertia.begin(source,target,previousTarget,0.f); inertia.apply(target,0.f,output); CHECK(output.local(0).qw==-1.f); } TEST_CASE("animation.motionMatching.inertiaRunsThroughMatcherSearchAndAdvance") { AnimSkeleton skeleton; skeleton.addBone("root"); skeleton.addBone("foot",0); AnimClip first("first"), second("second"); for(auto* clip : {&first,&second}) {clip->setDuration(2.f);clip->setLoop(false);} first.addPositionKey(1,0.f,0.f,0.f,0.f); first.addPositionKey(1,2.f,6.f,0.f,0.f); second.addPositionKey(1,0.f,7.f,0.f,0.f); second.addPositionKey(1,2.f,9.f,0.f,0.f); MotionDatabase db(&skeleton); db.addFeatureBone(1);db.addClip(&first);db.addClip(&second);db.bake(); MotionMatcher matcher(&skeleton,&db);matcher.setSearchInterval(10.f);matcher.setBlendTime(.3f); std::vector candidates{{0,0.f,1.f}}; REQUIRE(matcher.setCandidateRanges(candidates).ok());matcher.search(); matcher.update(.01f);matcher.update(.01f); float before=matcher.getPose()->getLocalPositionX(1); candidates[0].clipIndex=1; REQUIRE(matcher.setCandidateRanges(candidates).ok());matcher.search(); CHECK(std::abs(matcher.getPose()->getLocalPositionX(1)-before)<1e-5f); matcher.update(.0001f); CHECK(std::abs((matcher.getPose()->getLocalPositionX(1)-before)/.0001f-3.f)<.02f); matcher.update(.3f); AnimPose expected; second.sample(matcher.getMatchedTime(),&expected,&skeleton); CHECK(std::abs(matcher.getPose()->getLocalPositionX(1)-expected.getLocalPositionX(1))<1e-5f); }