diff --git a/cmake/build_variables.bzl b/cmake/build_variables.bzl index 84b010427d..c46c2792b1 100644 --- a/cmake/build_variables.bzl +++ b/cmake/build_variables.bzl @@ -309,9 +309,16 @@ character_solver_sources = [ character_solver_test_sources = [ "test/character_solver/blend_shape_test.cpp", "test/character_solver/camera_projection_error_function_test.cpp", - "test/character_solver/error_functions_test.cpp", + "test/character_solver/collision_error_function_test.cpp", + "test/character_solver/geometric_error_function_test.cpp", "test/character_solver/inverse_kinematics_test.cpp", + "test/character_solver/limit_error_function_test.cpp", + "test/character_solver/orientation_error_function_test.cpp", + "test/character_solver/position_error_function_test.cpp", + "test/character_solver/skinned_locator_error_function_test.cpp", "test/character_solver/solver_test.cpp", + "test/character_solver/state_error_function_test.cpp", + "test/character_solver/vertex_error_function_test.cpp", ] simd_constraints_public_headers = [ diff --git a/momentum/math/mesh.cpp b/momentum/math/mesh.cpp index a94b83b48f..14a33bb6c4 100644 --- a/momentum/math/mesh.cpp +++ b/momentum/math/mesh.cpp @@ -69,6 +69,9 @@ MeshT MeshT::cast() const { result.texcoords = this->texcoords; result.texcoord_faces = this->texcoord_faces; result.texcoord_lines = this->texcoord_lines; + result.polyFaces = this->polyFaces; + result.polyFaceSizes = this->polyFaceSizes; + result.polyTexcoordFaces = this->polyTexcoordFaces; return result; } @@ -83,6 +86,9 @@ void MeshT::reset() { texcoords.clear(); texcoord_faces.clear(); texcoord_lines.clear(); + polyFaces.clear(); + polyFaceSizes.clear(); + polyTexcoordFaces.clear(); } template MeshT MeshT::cast() const; diff --git a/momentum/math/mesh.h b/momentum/math/mesh.h index 01c2e54076..9eb7da9d3f 100644 --- a/momentum/math/mesh.h +++ b/momentum/math/mesh.h @@ -56,6 +56,26 @@ struct MeshT { /// Maps each line to its corresponding texture coordinates std::vector> texcoord_lines; + /// Packed polygon face vertex indices (all polygons concatenated back-to-back). + /// + /// For example, a quad followed by a triangle would be: [v0, v1, v2, v3, v4, v5, v6]. + /// This is an optional, redundant representation — the triangulated @ref faces field is always + /// required and used by all downstream code. Polygon data preserves the original topology (quads, + /// n-gons) from source files like FBX. + std::vector polyFaces; + + /// Number of vertices in each polygon face. + /// + /// For example, [4, 3] means the first polygon is a quad, the second is a triangle. + /// May be empty if polygon data is not available; see @ref polyFaces. + std::vector polyFaceSizes; + + /// Packed polygon face texture coordinate indices (same layout as polyFaces). + /// + /// Uses the same polyFaceSizes array — polygon sizes are identical for geometry and texcoords. + /// May be empty if texture coordinates are not available; see @ref polyFaces. + std::vector polyTexcoordFaces; + /// Compute vertex normals by averaging connected face normals. /// /// This method calculates normals for each vertex by averaging the normals of all diff --git a/momentum/test/character/character_helpers_gtest.cpp b/momentum/test/character/character_helpers_gtest.cpp index b726680f2a..7147603cb6 100644 --- a/momentum/test/character/character_helpers_gtest.cpp +++ b/momentum/test/character/character_helpers_gtest.cpp @@ -100,6 +100,12 @@ void compareMeshes(const Mesh_u& refMesh, const Mesh_u& mesh) { refMesh->confidence, testing::Pointwise(testing::DoubleNear(0.0001), mesh->confidence)); EXPECT_THAT( refMesh->texcoord_faces, testing::Pointwise(IntExactPointwise(), mesh->texcoord_faces)); + // Only compare polygon data when both meshes have it (e.g. glTF doesn't populate poly fields) + if (!refMesh->polyFaces.empty() && !mesh->polyFaces.empty()) { + EXPECT_EQ(refMesh->polyFaces, mesh->polyFaces); + EXPECT_EQ(refMesh->polyFaceSizes, mesh->polyFaceSizes); + EXPECT_EQ(refMesh->polyTexcoordFaces, mesh->polyTexcoordFaces); + } } void compareBlendShapes(const BlendShape_const_p& refShapes, const BlendShape_const_p& shapes) { diff --git a/momentum/test/character_solver/collision_error_function_test.cpp b/momentum/test/character_solver/collision_error_function_test.cpp new file mode 100644 index 0000000000..d0c7371ff8 --- /dev/null +++ b/momentum/test/character_solver/collision_error_function_test.cpp @@ -0,0 +1,150 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/sdf_collision_geometry.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/collision_error_function.h" +#include "momentum/character_solver/sdf_collision_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +#include + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, SDFCollisionError_WorldSDF_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // Create test character + const Character character = createTestCharacter(5); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // Create a simple cubic SDF using helper function + auto sdfWorld = std::make_shared>( + axel::SignedDistanceField::createSphere(5.0, Eigen::Vector3(10, 10, 10))); + + auto sdfLocal = std::make_shared>( + axel::SignedDistanceField::createSphere(3.0, Eigen::Vector3(10, 10, 10))); + + // Create SDF collision geometry + SDFCollisionGeometry sdfColliders; + + // Joint-attached SDF for testing both mesh and collider hierarchy gradients + sdfColliders.emplace_back( + SDFCollider{ + TransformT(Eigen::Vector3f(-0.224f, 0.134f, 0.151f)), + 2, // parent joint index + sdfLocal}); + + // Create error function with selected vertices (use first few vertices) + const size_t numTestVertices = std::min(size_t(5), character.mesh->vertices.size()); + std::vector participatingVertices; + std::vector vertexWeights; + + for (size_t i = 0; i < numTestVertices; ++i) { + participatingVertices.push_back(static_cast(i)); + vertexWeights.push_back(uniform(0.1, 2.0)); // Random weights + } + + // don't filter out rest pose intersections because this makes it very hard to construct a + // test case that has actual collisions: + bool filterRestPoseIntersections = false; + SDFCollisionErrorFunctionT errorFunction( + character, sdfColliders, participatingVertices, vertexWeights, filterRestPoseIntersections); + + { + SCOPED_TRACE("SDF Collision Error Test"); + + // Test with zero parameters (bind pose) + const ModelParametersT zeroParams = + ModelParametersT::Zero(transform.numAllModelParameters()); + const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); + const MeshStateT zeroMeshState(zeroParams, zeroState, character); + + // Check that error function is set up correctly + const double zeroError = errorFunction.getError(zeroParams, zeroState, zeroMeshState); + EXPECT_GT(zeroError, 0.0); // Error should be non-negative + + // Test gradients and jacobian for bind pose + // Note: SDF collision gradients have higher error tolerance due to: + // 1. Gauss-Newton approximation (surface point moves as mesh moves, but we treat it as fixed) + // 2. SDF gradient interpolation from discrete grid + // 3. Float precision issues in numerical differentiation for root parameters + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + zeroParams, + character, + Eps(0.1f, 0.04), + Eps(1e-6f, 1e-14), + true, + true); + + // Test with random parameters to create different collision scenarios + for (size_t i = 0; i < 5; i++) { + ModelParametersT parameters = + 0.1 * uniform>(transform.numAllModelParameters(), -1, 1); + + const SkeletonStateT state(transform.apply(parameters), skeleton); + const MeshStateT meshState(parameters, state, character); + const double error = errorFunction.getError(parameters, state, meshState); + EXPECT_GE(error, 0.0); // Error should be non-negative + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(0.25f, 0.04), + Eps(1e-6f, 1e-14), + true, + true); + } + } + + // Test error increases with more penetration + { + SCOPED_TRACE("Penetration Scaling Test"); + + // Create parameters that move joint 1 toward the SDF center + ModelParametersT baseParams = ModelParametersT::Zero(transform.numAllModelParameters()); + + // Find translation parameter for joint 1 + const size_t joint1TranslationBase = 1 * kParametersPerJoint + 1; // Joint 1, translation params + + // Test with increasing translation toward SDF center + double previousError = 0.0; + for (int step = 0; step < 3; ++step) { + ModelParametersT params = baseParams; + params[joint1TranslationBase] += static_cast(0.1); // Move in positive Y direction + + const SkeletonStateT state(transform.apply(params), skeleton); + const MeshStateT meshState(params, state, character); + const double error = errorFunction.getError(params, state, meshState); + + EXPECT_GE(error, 0.0); + + // Error should decrease as we move away from the SDF + if (step > 0) { + EXPECT_LE(error, previousError); + } + + previousError = error; + } + } +} diff --git a/momentum/test/character_solver/error_functions_test.cpp b/momentum/test/character_solver/error_functions_test.cpp deleted file mode 100644 index fdd854d03a..0000000000 --- a/momentum/test/character_solver/error_functions_test.cpp +++ /dev/null @@ -1,2681 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include -#include -#include - -#include -#include - -#include "momentum/character/character.h" -#include "momentum/character/linear_skinning.h" -#include "momentum/character/mesh_state.h" -#include "momentum/character/parameter_transform.h" -#include "momentum/character/sdf_collision_geometry.h" -#include "momentum/character/skeleton.h" -#include "momentum/character/skeleton_state.h" -#include "momentum/character/skin_weights.h" -#include "momentum/character_solver/aim_error_function.h" -#include "momentum/character_solver/collision_error_function.h" -#include "momentum/character_solver/distance_error_function.h" -#include "momentum/character_solver/fixed_axis_error_function.h" -#include "momentum/character_solver/fwd.h" -#include "momentum/character_solver/height_error_function.h" -#include "momentum/character_solver/joint_to_joint_distance_error_function.h" -#include "momentum/character_solver/joint_to_joint_position_error_function.h" -#include "momentum/character_solver/limit_error_function.h" -#include "momentum/character_solver/model_parameters_error_function.h" -#include "momentum/character_solver/normal_error_function.h" -#include "momentum/character_solver/orientation_error_function.h" -#include "momentum/character_solver/plane_error_function.h" -#include "momentum/character_solver/point_triangle_vertex_error_function.h" -#include "momentum/character_solver/pose_prior_error_function.h" -#include "momentum/character_solver/position_error_function.h" -#include "momentum/character_solver/projection_error_function.h" -#include "momentum/character_solver/sdf_collision_error_function.h" -#include "momentum/character_solver/skinned_locator_error_function.h" -#include "momentum/character_solver/skinned_locator_triangle_error_function.h" -#include "momentum/character_solver/state_error_function.h" -#include "momentum/character_solver/vertex_error_function.h" -#include "momentum/character_solver/vertex_projection_error_function.h" -#include "momentum/character_solver/vertex_sdf_error_function.h" -#include "momentum/character_solver/vertex_vertex_distance_error_function.h" -#include "momentum/math/constants.h" -#include "momentum/math/mesh.h" -#include "momentum/math/random.h" -#include "momentum/math/utility.h" -#include "momentum/test/character/character_helpers.h" -#include "momentum/test/character_solver/error_function_helpers.h" - -#include - -using namespace momentum; - -using Types = testing::Types; - -TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); - -namespace { - -Character getSkinnedLocatorTestCharacter() { - Character character = withTestBlendShapes(createTestCharacter(4)); - - std::vector activeLocators(character.skinnedLocators.size(), true); - activeLocators[1] = false; - const auto [transform, limits] = addSkinnedLocatorParameters( - character.parameterTransform, character.parameterLimits, activeLocators); - - return { - character.skeleton, - transform, - limits, - character.locators, - character.mesh.get(), - character.skinWeights.get(), - character.collision.get(), - character.poseShapes.get(), - character.blendShape, - character.faceExpressionBlendShape, - character.name, - character.inverseBindPose, - character.skinnedLocators}; -} - -} // namespace - -TYPED_TEST(Momentum_ErrorFunctionsTest, LimitError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - LimitErrorFunctionT errorFunction(skeleton, character.parameterTransform); - - // TODO: None of these work right at the moment due to precision issues with numerical gradients, - // need to fix code to use double - { - SCOPED_TRACE("Limit MinMax Test"); - ParameterLimit limit; - limit.type = MinMax; - limit.weight = 1.0; - limit.data.minMax.limits = Vector2f(-0.1, 0.1); - limit.data.minMax.parameterIndex = 0; - errorFunction.setLimits({limit}); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-7f, 1e-15)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 1e-11)); - } - } - - { - SCOPED_TRACE("Limit MinMax Joint Test"); - ParameterLimit limit; - limit.type = MinMaxJoint; - limit.weight = 1.0; - limit.data.minMaxJoint.limits = Vector2f(-0.1, 0.1); - limit.data.minMaxJoint.jointIndex = 2; - limit.data.minMaxJoint.jointParameter = 5; - errorFunction.setLimits({limit}); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-7f, 1e-15)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-3f, 1e-10)); - } - } - - { - SCOPED_TRACE("Limit LinearTest"); - ParameterLimit limit; - limit.type = Linear; - limit.weight = 1.5; - limit.data.linear.referenceIndex = 0; - limit.data.linear.targetIndex = 5; - limit.data.linear.scale = 0.25; - limit.data.linear.offset = 0.25; - errorFunction.setLimits({limit}); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-3f, 5e-12)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(2e-2f, 1e-10)); - } - } - - { - SCOPED_TRACE("Limit PiecewiseLinearTest"); - ParameterLimits limits; - - ParameterLimit limit; - limit.type = LimitType::Linear; - limit.data.linear.referenceIndex = 0; - limit.data.linear.targetIndex = 5; - limit.weight = 0.5f; - - { - ParameterLimit cur = limit; - cur.data.linear.scale = -1.0f; - cur.data.linear.offset = 3.0f; - cur.data.linear.rangeMin = -FLT_MAX; - cur.data.linear.rangeMax = -3.0f; - limits.push_back(cur); - } - - { - ParameterLimit cur = limit; - cur.data.linear.scale = 1.0f; - cur.data.linear.offset = -3.0f; - cur.data.linear.rangeMin = -3.0f; - cur.data.linear.rangeMax = FLT_MAX; - limits.push_back(cur); - } - - errorFunction.setLimits(limits); - - // Verify that gradients are ok on either side of the first-derivative discontinuity: - ModelParametersT parametersBefore = VectorX::Zero(transform.numAllModelParameters()); - parametersBefore(limit.data.linear.targetIndex) = -3.01f; - const SkeletonStateT skelStateBefore( - character.parameterTransform.cast().apply(parametersBefore), character.skeleton); - Eigen::VectorX gradBefore = VectorX::Zero(transform.numAllModelParameters()); - errorFunction.getGradient(parametersBefore, skelStateBefore, MeshStateT(), gradBefore); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parametersBefore, character, Eps(1e-3f, 1e-10)); - - ModelParametersT parametersAfter = VectorX::Zero(transform.numAllModelParameters()); - parametersAfter(limit.data.linear.targetIndex) = -2.99f; - const SkeletonStateT skelStateAfter( - character.parameterTransform.cast().apply(parametersAfter), character.skeleton); - Eigen::VectorX gradAfter = VectorX::Zero(transform.numAllModelParameters()); - errorFunction.getGradient(parametersAfter, skelStateAfter, MeshStateT(), gradAfter); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parametersAfter, character, Eps(1e-3f, 1e-10)); - - // Gradient won't be the same at the point 3.0: - ModelParametersT parametersMid = VectorX::Zero(transform.numAllModelParameters()); - parametersMid(limit.data.linear.targetIndex) = -3.0f; - const SkeletonStateT skelStateMid( - character.parameterTransform.cast().apply(parametersMid), character.skeleton); - - // Verify that the error is C0 continuous: - const float errorBefore = - errorFunction.getError(parametersBefore, skelStateBefore, MeshStateT()); - const float errorMid = errorFunction.getError(parametersMid, skelStateMid, MeshStateT()); - const float errorAfter = - errorFunction.getError(parametersAfter, skelStateAfter, MeshStateT()); - - ASSERT_NEAR(errorBefore, errorMid, 0.03f); - ASSERT_NEAR(errorMid, errorAfter, 0.03f); - } - - { - SCOPED_TRACE("LimitJoint PiecewiseLinearTest"); - ParameterLimits limits; - - ParameterLimit limit; - limit.type = LimitType::LinearJoint; - limit.data.linearJoint.referenceJointIndex = 0; - limit.data.linearJoint.referenceJointParameter = 2; - limit.data.linearJoint.targetJointIndex = 1; - limit.data.linearJoint.targetJointParameter = 5; - limit.weight = 0.75f; - - { - ParameterLimit cur = limit; - cur.data.linearJoint.scale = 1.0f; - cur.data.linearJoint.offset = -4.0f; - cur.data.linearJoint.rangeMin = -FLT_MAX; - cur.data.linearJoint.rangeMax = 0.0f; - limits.push_back(cur); - } - - { - ParameterLimit cur = limit; - cur.data.linearJoint.scale = -1.0f; - cur.data.linearJoint.offset = -4.0f; - cur.data.linearJoint.rangeMin = 0.0f; - cur.data.linearJoint.rangeMax = 2.0; - limits.push_back(cur); - } - - { - ParameterLimit cur = limit; - cur.data.linearJoint.scale = 1.0f; - cur.data.linearJoint.offset = 0.0f; - cur.data.linearJoint.rangeMin = 2.0f; - cur.data.linearJoint.rangeMax = FLT_MAX; - limits.push_back(cur); - } - - errorFunction.setLimits(limits); - - auto rz_param = character.parameterTransform.getParameterIdByName("shared_rz"); - ASSERT_NE(rz_param, momentum::kInvalidIndex); - - for (const auto& testPos : {-4.0, 0.0, 4.0}) { - // Verify that gradients are ok on either side of the first-derivative discontinuity: - ModelParametersT parametersBefore = VectorX::Zero(transform.numAllModelParameters()); - parametersBefore(rz_param) = testPos - 0.001f; - const SkeletonStateT skelStateBefore( - character.parameterTransform.cast().apply(parametersBefore), character.skeleton); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parametersBefore, character, Eps(5e-2f, 1e-10)); - - ModelParametersT parametersAfter = VectorX::Zero(transform.numAllModelParameters()); - parametersAfter(rz_param) = testPos + 0.001f; - const SkeletonStateT skelStateAfter( - character.parameterTransform.cast().apply(parametersAfter), character.skeleton); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parametersAfter, character, Eps(5e-2f, 1e-10)); - - // Verify that the error is C0 continuous: - const float errorBefore = - errorFunction.getError(parametersBefore, skelStateBefore, MeshStateT()); - const float errorAfter = - errorFunction.getError(parametersAfter, skelStateAfter, MeshStateT()); - ASSERT_NEAR(errorAfter, errorBefore, 0.03f); - - // Make sure the parameter actually varies: - const auto targetJointParameter = - limit.data.linearJoint.targetJointIndex * kParametersPerJoint + - limit.data.linearJoint.targetJointParameter; - EXPECT_GT( - std::abs( - skelStateBefore.jointParameters[targetJointParameter] - - skelStateAfter.jointParameters[targetJointParameter]), - 0.0005f); - } - } - - { - SCOPED_TRACE("Limit HalfPlaneTest"); - - ParameterLimit limit; - limit.type = HalfPlane; - limit.weight = 1.0; - limit.data.halfPlane.param1 = 0; - limit.data.halfPlane.param2 = 2; - limit.data.halfPlane.normal = Eigen::Vector2f(1, -1).normalized(); - limit.data.halfPlane.offset = 0.5f; - errorFunction.setLimits({limit}); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-3f, 5e-12)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 1e-10)); - } - } - - //{ - // SCOPED_TRACE("Limit Ellipsoid Test"); - // limit.type = ELLIPSOID; - // limit.weight = 1.0f; - // limit.data.ellipsoid.parent = 2; - // limit.data.ellipsoid.ellipsoidParent = 0; - // limit.data.ellipsoid.offset = Vector3f(0, -1, 0); - // limit.data.ellipsoid.ellipsoid = Affine3f::Identity(); - // limit.data.ellipsoid.ellipsoid.translation() = Vector3f(0.5f, 0.5f, 0.5f); - // limit.data.ellipsoid.ellipsoid.linear() = (Quaternionf(Eigen::AngleAxisf(0.1f, - // Vector3f::UnitZ())) * - // Quaternionf(Eigen::AngleAxisf(0.2f, - // Vector3f::UnitY())) * - // Quaternionf(Eigen::AngleAxisf(0.3f, - // Vector3f::UnitX()))).toRotationMatrix() * - // Scaling(2.0f, 1.5f, 0.5f); - // limit.data.ellipsoid.ellipsoidInv = limit.data.ellipsoid.ellipsoid.inverse(); - - // errorFunction.setLimits(lm); - // parameters.setZero(); - // testGradientAndJacobian(&errorFunction, parameters, skeleton, transform); - // for (size_t i = 0; i < 3; i++) - // { - // parameters = VectorXd::Random(transform.numAllModelParameters()); - // testGradientAndJacobian(&errorFunction, parameters, skeleton, transform); - // } - //} -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, StateError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - StateErrorFunctionT errorFunction(skeleton, character.parameterTransform); - { - SCOPED_TRACE("State Test"); - SkeletonStateT reference(transform.bindPose(), skeleton); - errorFunction.setTargetState(reference); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(2e-5f, 1e-3)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 5e-6)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, StateErrorLogMap_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - StateErrorFunctionT errorFunction( - skeleton, character.parameterTransform, RotationErrorType::QuaternionLogMap); - { - SCOPED_TRACE("State Test"); - SkeletonStateT reference(transform.bindPose(), skeleton); - errorFunction.setTargetState(reference); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(2e-5f, 1e-3)); - EXPECT_EQ(errorFunction.getJacobianSize(), 6 * character.skeleton.joints.size()); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 5e-6)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexVertexDistanceError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); - - // create skeleton and reference values - const size_t nConstraints = 10; - - // Test WITHOUT blend shapes: - { - SCOPED_TRACE("Without blend shapes"); - - const Character character_orig = createTestCharacter(); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ParameterTransformT transform = character_orig.parameterTransform.cast(); - - VertexVertexDistanceErrorFunctionT errorFunction(character_orig); - - // Add some vertex-vertex distance constraints - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int vertexIndex1 = - uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); - int vertexIndex2 = - uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); - - // Ensure we don't constrain the same vertex to itself - while (vertexIndex2 == vertexIndex1) { - vertexIndex2 = uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); - } - - const T weight = uniform(0.1, 2.0); - const T targetDistance = uniform(0.1, 1.0); - - errorFunction.addConstraint(vertexIndex1, vertexIndex2, weight, targetDistance); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_orig, - Eps(5e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - } - - // Test WITH blend shapes: - { - SCOPED_TRACE("With blend shapes"); - - const Character character_blend = withTestBlendShapes(createTestCharacter()); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ParameterTransformT transform = character_blend.parameterTransform.cast(); - - VertexVertexDistanceErrorFunctionT errorFunction(character_blend); - - // Add some vertex-vertex distance constraints - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int vertexIndex1 = - uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); - int vertexIndex2 = - uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); - - // Ensure we don't constrain the same vertex to itself - while (vertexIndex2 == vertexIndex1) { - vertexIndex2 = uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); - } - - const T weight = uniform(0.1, 2.0); - const T targetDistance = uniform(0.1, 1.0); - - errorFunction.addConstraint(vertexIndex1, vertexIndex2, weight, targetDistance); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_blend, - Eps(1e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - } - - // Test that error is zero when vertices are at target distance - { - SCOPED_TRACE("Zero error test"); - - const Character character = createTestCharacter(); - const ModelParametersT modelParams = - ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()); - const SkeletonStateT skelState( - character.parameterTransform.cast().apply(modelParams), character.skeleton); - - VertexVertexDistanceErrorFunctionT errorFunction(character); - - // Calculate actual distance between two vertices - momentum::TransformationListT ibp; - for (const auto& js : character.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character.mesh->cast(); - const auto& skin = *character.skinWeights; - momentum::MeshT posedMesh = character.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, posedMesh); - - const int vertexIndex1 = 0; - const int vertexIndex2 = 1; - const T actualDistance = - (posedMesh.vertices[vertexIndex1] - posedMesh.vertices[vertexIndex2]).norm(); - - // Add constraint with the actual distance as target - errorFunction.addConstraint(vertexIndex1, vertexIndex2, T(1.0), actualDistance); - - const double error = errorFunction.getError( - modelParams, skelState, MeshStateT(modelParams, skelState, character)); - EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, ModelParametersError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - ModelParametersErrorFunctionT errorFunction(skeleton, character.parameterTransform); - { - SCOPED_TRACE("Motion Test"); - SkeletonStateT reference(transform.bindPose(), skeleton); - VectorX weights = VectorX::Ones(transform.numAllModelParameters()); - weights(0) = 4.0; - weights(1) = 5.0; - weights(2) = 0.0; - errorFunction.setTargetParameters( - ModelParametersT::Zero(transform.numAllModelParameters()), weights); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-7f, 1e-15)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-3f, 1e-10)); - } - } -} - -// Show an example of regularizing the blend weights: -TYPED_TEST(Momentum_ErrorFunctionsTest, ModelParametersError_RegularizeBlendWeights) { - using T = typename TestFixture::Type; - - const Character character = withTestBlendShapes(createTestCharacter()); - const ModelParametersT modelParams = - 0.25 * uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); - ModelParametersErrorFunctionT errorFunction( - character, character.parameterTransform.getBlendShapeParameters()); - EXPECT_GT( - errorFunction.getError( - modelParams, - SkeletonStateT( - character.parameterTransform.cast().apply(modelParams), character.skeleton), - MeshStateT()), - 0); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, modelParams, character, Eps(1e-3f, 1e-10), Eps(1e-7f, 1e-7), true); - - ModelParametersErrorFunctionT errorFunction2( - character, character.parameterTransform.getScalingParameters()); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction2, modelParams, character, Eps(1e-3f, 5e-12), Eps(1e-6f, 1e-7), true); -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PosePriorError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PosePriorErrorFunctionT errorFunction( - character.skeleton, character.parameterTransform, createDefaultPosePrior()); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-1f, 1e-10), - Eps(1e-5f, 5e-6)); - - for (size_t i = 0; i < 3; i++) { - ModelParametersT parameters = uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(5e-1f, 1e-9), Eps(5e-5f, 5e-6)); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, SkinnedLocatorError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // Create a test character with skinned locators - const Character character = getSkinnedLocatorTestCharacter(); - const ParameterTransformT transform = character.parameterTransform.cast(); - // Verify that the character has skinned locators - ASSERT_GT(character.skinnedLocators.size(), 2); - - // Create the error function - SkinnedLocatorErrorFunctionT errorFunction(character); - - // Add constraints for some of the skinned locators - const T kTestWeightValue = 4.5; - for (int i = 0; i < std::min(3, static_cast(character.skinnedLocators.size())); ++i) { - errorFunction.addConstraint( - i, kTestWeightValue, uniform>(-1, 1)); // Random target position - } - - // Test with random parameters - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6)); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PointTriangleSkinnedLocatorError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // Create a test character with skinned locators - const Character character = getSkinnedLocatorTestCharacter(); - const ParameterTransformT transform = character.parameterTransform.cast(); - - // Verify that the character has skinned locators - ASSERT_GT(character.skinnedLocators.size(), 0); - - // Create the error function with Plane constraint - for (const auto vertexConstraintType : - {VertexConstraintType::Plane, VertexConstraintType::Position}) { - SkinnedLocatorTriangleErrorFunctionT errorFunction(character, vertexConstraintType); - - // Add constraints for some of the skinned locators - const T kTestWeightValue = 5.0; - for (int i = 0; i < character.skinnedLocators.size(); ++i) { - // Create a simple triangle constraint - Eigen::Vector3i triangleIndices = - character.mesh->faces[uniform(0, character.mesh->faces.size() - 1)]; - auto triangleBaryCoords = uniform>(0, 1); - triangleBaryCoords /= triangleBaryCoords.sum(); - T depth = 0.5; - - errorFunction.addConstraint(i, triangleIndices, triangleBaryCoords, depth, kTestWeightValue); - } - - // Test with random parameters - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(0.03, 1e-3), true, true); - } - } -} - -// Test that sliding constraints correctly slide to nearest candidate triangle -TYPED_TEST(Momentum_ErrorFunctionsTest, SkinnedLocatorTriangle_SlidingConstraints) { - using T = typename TestFixture::Type; - - // Create a test character with skinned locators - const Character character = getSkinnedLocatorTestCharacter(); - const ParameterTransformT transform = character.parameterTransform.cast(); - const Mesh& mesh = *character.mesh; - - ASSERT_GT(character.skinnedLocators.size(), 0); - ASSERT_GT(mesh.faces.size(), 3); // Need at least a few triangles - - // Test with Position constraint type (similar results expected for Plane) - SkinnedLocatorTriangleErrorFunctionT errorFunction(character, VertexConstraintType::Position); - - // Pick three triangles for testing: initial triangle and two candidates - const size_t initialTriIdx = 0; - const size_t candidateTriIdx1 = 1; - const size_t candidateTriIdx2 = 2; - const size_t excludedTriIdx = 3; // Not in candidate list - - const Eigen::Vector3i initialTriIndices = mesh.faces[initialTriIdx]; - const Eigen::Vector3i candidateTri1Indices = mesh.faces[candidateTriIdx1]; - const Eigen::Vector3i candidateTri2Indices = mesh.faces[candidateTriIdx2]; - const Eigen::Vector3i excludedTriIndices = mesh.faces[excludedTriIdx]; - - // Create constraint with candidate triangles - SkinnedLocatorTriangleConstraintT constraint; - constraint.locatorIndex = 0; - constraint.tgtTriangleIndices = initialTriIndices; - constraint.tgtTriangleBaryCoords = Eigen::Vector3(T(1.0 / 3.0), T(1.0 / 3.0), T(1.0 / 3.0)); - constraint.depth = T(0); - constraint.weight = T(1); - - // Add candidate triangles (including the initial one) - CandidateTriangle candidate0; - candidate0.triangleIdx = initialTriIdx; - candidate0.vertexIndices = initialTriIndices; - constraint.candidateTriangles.push_back(candidate0); - - CandidateTriangle candidate1; - candidate1.triangleIdx = candidateTriIdx1; - candidate1.vertexIndices = candidateTri1Indices; - constraint.candidateTriangles.push_back(candidate1); - - CandidateTriangle candidate2; - candidate2.triangleIdx = candidateTriIdx2; - candidate2.vertexIndices = candidateTri2Indices; - constraint.candidateTriangles.push_back(candidate2); - - // Set the constraint - std::vector> constraints = {constraint}; - errorFunction.setConstraints(constraints); - - // Create parameters and state first - ModelParametersT params = ModelParametersT::Zero(transform.numAllModelParameters()); - SkeletonStateT state(transform.apply(params), character.skeleton); - - // Set up mesh state using the MeshStateT constructor - MeshStateT meshState(params, state, character); - - // Helper to compute centroid of a triangle - auto getTriangleCentroid = [&mesh](size_t triIdx) -> Eigen::Vector3 { - const Eigen::Vector3i& face = mesh.faces[triIdx]; - return (mesh.vertices[face(0)].template cast() + mesh.vertices[face(1)].template cast() + - mesh.vertices[face(2)].template cast()) / - T(3); - }; - - // Get centroids for testing - const Eigen::Vector3 initialCentroid = getTriangleCentroid(initialTriIdx); - const Eigen::Vector3 candidate1Centroid = getTriangleCentroid(candidateTriIdx1); - const Eigen::Vector3 candidate2Centroid = getTriangleCentroid(candidateTriIdx2); - const Eigen::Vector3 excludedCentroid = getTriangleCentroid(excludedTriIdx); - - // Get the skinned locator parameter index - const SkinnedLocator& locator = character.skinnedLocators[0]; - int locatorParamIdx = -1; - if (character.parameterTransform.skinnedLocatorParameters.size() > 0) { - locatorParamIdx = character.parameterTransform.skinnedLocatorParameters[0]; - } - ASSERT_GE(locatorParamIdx, 0); // Ensure the locator has parameters - - // Test 1: Position locator at initial triangle centroid - error should be near zero - { - Eigen::Vector3 offset = initialCentroid - locator.position.template cast(); - params.v.template segment<3>(locatorParamIdx) = offset; - double errorAtInitial = errorFunction.getError(params, state, meshState); - // Error should be small (locator is at the target) - EXPECT_LT(errorAtInitial, T(0.01)) << "Error at initial triangle should be near zero"; - } - - // Test 2: Position locator at candidate1 centroid - should slide to it with low error - { - Eigen::Vector3 offset = candidate1Centroid - locator.position.template cast(); - params.v.template segment<3>(locatorParamIdx) = offset; - double errorAtCandidate1 = errorFunction.getError(params, state, meshState); - // Error should be small since it can slide to candidate1 - EXPECT_LT(errorAtCandidate1, T(0.01)) << "Error at candidate1 should be near zero (sliding)"; - } - - // Test 3: Position locator at candidate2 centroid - should slide to it with low error - { - Eigen::Vector3 offset = candidate2Centroid - locator.position.template cast(); - params.v.template segment<3>(locatorParamIdx) = offset; - double errorAtCandidate2 = errorFunction.getError(params, state, meshState); - // Error should be small since it can slide to candidate2 - EXPECT_LT(errorAtCandidate2, T(0.01)) << "Error at candidate2 should be near zero (sliding)"; - } - - // Test 4: Position locator at excluded triangle centroid - should NOT slide there - // Error should be larger since it can only slide to candidates - { - Eigen::Vector3 offset = excludedCentroid - locator.position.template cast(); - params.v.template segment<3>(locatorParamIdx) = offset; - double errorAtExcluded = errorFunction.getError(params, state, meshState); - - // Now create a constraint WITHOUT candidates (fixed to initial triangle) - SkinnedLocatorTriangleConstraintT fixedConstraint; - fixedConstraint.locatorIndex = 0; - fixedConstraint.tgtTriangleIndices = initialTriIndices; - fixedConstraint.tgtTriangleBaryCoords = - Eigen::Vector3(T(1.0 / 3.0), T(1.0 / 3.0), T(1.0 / 3.0)); - fixedConstraint.depth = T(0); - fixedConstraint.weight = T(1); - // No candidate triangles - should behave as fixed constraint - - SkinnedLocatorTriangleErrorFunctionT fixedErrorFunction( - character, VertexConstraintType::Position); - fixedErrorFunction.setConstraints({fixedConstraint}); - - double fixedErrorAtExcluded = fixedErrorFunction.getError(params, state, meshState); - - // The sliding error should be less than or equal to the fixed error - // because sliding can find a closer point among candidates - // (unless excluded triangle happens to be the same as initial, which we avoid) - - // Both errors should be non-zero since the locator is at the excluded triangle - // but the sliding constraint finds the closest candidate while fixed stays at initial - EXPECT_GT(errorAtExcluded, T(0.0)) - << "Error at excluded triangle should be non-zero for sliding constraint"; - EXPECT_GT(fixedErrorAtExcluded, T(0.0)) - << "Error at excluded triangle should be non-zero for fixed constraint"; - } - - // Note: We intentionally don't test gradients/jacobians for sliding constraints - // because the sliding introduces non-differentiable discontinuities when switching - // between triangles. The Jacobian is computed as if the current triangle is fixed, - // which is a common approximation for closest-point constraints, but it won't match - // numerical gradients at switching boundaries. -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, TestSkinningErrorFunction) { - using T = typename TestFixture::Type; - - // this unit tests checks the accuracy of our linear skinning constraint accuracy - // against our simplified approximation that's way faster. - // we expect our gradients to be within 10% of the true gradients of the mesh - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - const auto mesh = character.mesh->cast(); - const auto& skin = *character.skinWeights; - VectorX parameters = VectorX::Zero(transform.numAllModelParameters()); - - VectorX gradient = VectorX::Zero(transform.numAllModelParameters()); - - // create constraints - std::vector> cl; - PositionErrorFunctionT errorFunction(skeleton, character.parameterTransform); - SkeletonStateT bindState(transform.apply(parameters), skeleton); - SkeletonStateT state(transform.apply(parameters), skeleton); - MeshStateT meshState; - TransformationListT bindpose; - for (const auto& js : bindState.jointState) { - bindpose.push_back(js.transform.inverse().toAffine3()); - } - - { - SCOPED_TRACE("Skinning mesh constraint test"); - - for (size_t vi = 0; vi < mesh.vertices.size(); vi++) { - const Eigen::Vector3 target = mesh.vertices[vi]; - - // add vertex to constraint list - cl.clear(); - for (size_t si = 0; si < kMaxSkinJoints; si++) { - if (skin.weight(vi, si) == 0.0) { - continue; - } - const auto parent = skin.index(vi, si); - cl.push_back( - PositionDataT( - (target - bindState.jointState[parent].translation()), - target, - parent, - skin.weight(vi, si))); - } - errorFunction.setConstraints(cl); - - std::vector> v = applySSD(bindpose, skin, mesh.vertices, bindState); - - // check position of skinning - EXPECT_LE((v[vi] - target).norm(), Eps(2e-7f, 2e-7)); - - // check error - gradient.setZero(); - T gradientError = errorFunction.getGradient(parameters, bindState, meshState, gradient); - EXPECT_NEAR(gradientError, 0, Eps(1e-15f, 1e-15)); - EXPECT_LE(gradient.norm(), Eps(1e-7f, 1e-7)); - - for (size_t i = 0; i < 10; i++) { - parameters = uniform>(transform.numAllModelParameters(), -1, 1); - state.set(transform.apply(parameters), skeleton); - v = applySSD(bindpose, skin, mesh.vertices, state); - - cl.clear(); - for (size_t si = 0; si < kMaxSkinJoints; si++) { - if (skin.weight(vi, si) == 0.0) { - continue; - } - const auto parent = skin.index(vi, si); - const Vector3 offset = state.jointState[parent].transform.inverse() * v[vi]; - cl.push_back(PositionDataT(offset, target, parent, skin.weight(vi, si))); - } - errorFunction.setConstraints(cl); - - gradient.setZero(); - gradientError = errorFunction.getGradient(parameters, state, meshState, gradient); - auto numError = (v[vi] - target).squaredNorm(); - EXPECT_NEAR(gradientError, numError, Eps(1e-5f, 1e-5)); - - // calculate numerical gradient - constexpr T kStepSize = 1e-5; - VectorX numGradient = VectorX::Zero(transform.numAllModelParameters()); - for (auto p = 0; p < transform.numAllModelParameters(); p++) { - // perform higher-order finite differences for accuracy - VectorX params = parameters; - params(p) = parameters(p) - kStepSize; - state.set(transform.apply(params), skeleton); - v = applySSD(bindpose, skin, mesh.vertices, state); - const T h_1 = (v[vi] - target).squaredNorm(); - - params(p) = parameters(p) + kStepSize; - state.set(transform.apply(params), skeleton); - v = applySSD(bindpose, skin, mesh.vertices, state); - const T h1 = (v[vi] - target).squaredNorm(); - - numGradient(p) = (h1 - h_1) / (2.0 * kStepSize); - } - - // check the gradients are similar - { - SCOPED_TRACE("Checking Numerical Gradient"); - if ((numGradient + gradient).norm() != 0.0) { - EXPECT_LE( - (numGradient - gradient).norm() / (numGradient + gradient).norm(), - Eps(1e-1f, 1e-1)); - } - } - } - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexErrorFunctionSerial) { - using T = typename TestFixture::Type; - SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); - - // create skeleton and reference values - - const size_t nConstraints = 10; - - // Test WITHOUT blend shapes: - { - SCOPED_TRACE("Without blend shapes"); - - const Character character_orig = createTestCharacter(); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ModelParametersT modelParamsTarget = refParams + - 0.05 * - uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const Skeleton& skeleton = character_orig.skeleton; - const ParameterTransformT transform = character_orig.parameterTransform.cast(); - const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); - momentum::TransformationListT ibp; - for (const auto& js : character_orig.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character_orig.mesh->cast(); - const auto& skin = *character_orig.skinWeights; - momentum::MeshT targetMesh = character_orig.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, targetMesh); - - for (VertexConstraintType type : - {VertexConstraintType::Position, - VertexConstraintType::Plane, - VertexConstraintType::Normal, - VertexConstraintType::SymmetricNormal}) { - SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); - - const T errorTol = [&]() { - switch (type) { - case VertexConstraintType::Position: - case VertexConstraintType::Plane: - return Eps(5e-2f, 1e-5); - - // TODO Normal constraints have a much higher epsilon than I'd prefer to see; - // it would be good to dig into this. - case VertexConstraintType::Normal: - case VertexConstraintType::SymmetricNormal: - return Eps(5e-2f, 5e-2); - - default: - // Shouldn't reach here - return T(0); - } - }(); - - VertexErrorFunctionT errorFunction(character_orig, type, 0); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int index = - uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); - errorFunction.addConstraint( - index, uniform(0, 1e-2), targetMesh.vertices[index], targetMesh.normals[index]); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_orig, - errorTol, - Eps(1e-6f, 1e-14), - true, - false); - } - } - - // Test WITH blend shapes: - { - SCOPED_TRACE("With blend shapes"); - - const Character character_blend = withTestBlendShapes(createTestCharacter()); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ModelParametersT modelParamsTarget = refParams + - 0.05 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - const Skeleton& skeleton = character_blend.skeleton; - const ParameterTransformT transform = character_blend.parameterTransform.cast(); - const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); - momentum::TransformationListT ibp; - for (const auto& js : character_blend.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character_blend.mesh->cast(); - const auto& skin = *character_blend.skinWeights; - momentum::MeshT targetMesh = character_blend.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, targetMesh); - - // It's trickier to test Normal and SymmetricNormal constraints in the blend shape case because - // the mesh normals are recomputed after blend shapes are applied (this is the only sensible - // thing to do since the blend shapes can drastically change the shape) and thus the normals - // depend on the blend shapes in a very complicated way that we aren't currently trying to - // model. - for (VertexConstraintType type : - {VertexConstraintType::Position, VertexConstraintType::Plane}) { - SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); - - VertexErrorFunctionT errorFunction(character_blend, type); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int index = - uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); - errorFunction.addConstraint( - index, uniform(0, 1e-2), targetMesh.vertices[index], targetMesh.normals[index]); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_blend, - Eps(1e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexErrorFunctionParallel) { - using T = typename TestFixture::Type; - SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); - - // create skeleton and reference values - - const size_t nConstraints = 1000; - - const Character character_orig = createTestCharacter(); - const Eigen::VectorX refParams = - 0.25 * uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ModelParametersT modelParamsTarget = refParams + - 0.05 * uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const Skeleton& skeleton = character_orig.skeleton; - const ParameterTransformT transform = character_orig.parameterTransform.cast(); - const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); - momentum::TransformationListT ibp; - for (const auto& js : character_orig.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character_orig.mesh->cast(); - const auto& skin = *character_orig.skinWeights; - momentum::MeshT targetMesh = character_orig.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, targetMesh); - - for (VertexConstraintType type : - {VertexConstraintType::Position, - VertexConstraintType::Plane, - VertexConstraintType::Normal, - VertexConstraintType::SymmetricNormal}) { - SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); - - const T errorTol = [&]() { - switch (type) { - case VertexConstraintType::Position: - case VertexConstraintType::Plane: - return Eps(5e-2f, 1e-5); - - // TODO Normal constraints have a much higher epsilon than I'd prefer to see; - // it would be good to dig into this. - case VertexConstraintType::Normal: - case VertexConstraintType::SymmetricNormal: - return Eps(5e-2f, 5e-2); - - default: - // Shouldn't reach here - return T(0); - } - }(); - - VertexErrorFunctionT errorFunction(character_orig, type, 100000); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int index = uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); - errorFunction.addConstraint( - index, uniform(0, 1e-4), targetMesh.vertices[index], targetMesh.normals[index]); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_orig, - errorTol, - Eps(1e-6f, 1e-15), - true, - false); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexProjectionErrorFunction) { - using T = typename TestFixture::Type; - SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); - - // create skeleton and reference values - - const size_t nConstraints = 10; - - // Test WITHOUT blend shapes: - { - SCOPED_TRACE("Without blend shapes"); - - const Character character_orig = createTestCharacter(); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ModelParametersT modelParamsTarget = refParams + - 0.05 * - uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); - const Skeleton& skeleton = character_orig.skeleton; - const ParameterTransformT transform = character_orig.parameterTransform.cast(); - const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); - momentum::TransformationListT ibp; - for (const auto& js : character_orig.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character_orig.mesh->cast(); - const auto& skin = *character_orig.skinWeights; - momentum::MeshT targetMesh = character_orig.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, targetMesh); - - Eigen::Matrix projection = Eigen::Matrix::Identity(); - projection(0, 0) = 10.0; - projection(1, 1) = 10.0; - projection(2, 3) = 10.0; - - const T errorTol = Eps(5e-2f, 1e-5); - - VertexProjectionErrorFunctionT errorFunction(character_orig, 0); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int index = uniform(0, character_orig.mesh->vertices.size() - 1); - const Eigen::Vector3 target = projection * targetMesh.vertices[index].homogeneous(); - const Eigen::Vector2 target2d = target.hnormalized() + uniform>(-1, 1) * 0.1; - errorFunction.addConstraint(index, uniform(0, 1e-2), target2d, projection); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_orig, - errorTol, - Eps(1e-6f, 1e-14), - true, - false); - } - - // Test WITH blend shapes: - { - SCOPED_TRACE("With blend shapes"); - - const Character character_blend = withTestBlendShapes(createTestCharacter()); - const Eigen::VectorX refParams = 0.25 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - const ModelParametersT modelParams = refParams; - const ModelParametersT modelParamsTarget = refParams + - 0.05 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - const Skeleton& skeleton = character_blend.skeleton; - const ParameterTransformT transform = character_blend.parameterTransform.cast(); - const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); - momentum::TransformationListT ibp; - for (const auto& js : character_blend.inverseBindPose) { - ibp.push_back(js.cast()); - } - const auto mesh = character_blend.mesh->cast(); - const auto& skin = *character_blend.skinWeights; - momentum::MeshT targetMesh = character_blend.mesh->cast(); - applySSD(ibp, skin, mesh, skelState, targetMesh); - - Eigen::Matrix projection = Eigen::Matrix::Identity(); - projection(2, 3) = 10; - - // It's trickier to test Normal and SymmetricNormal constraints in the blend shape case because - // the mesh normals are recomputed after blend shapes are applied (this is the only sensible - // thing to do since the blend shapes can drastically change the shape) and thus the normals - // depend on the blend shapes in a very complicated way that we aren't currently trying to - // model. - VertexProjectionErrorFunctionT errorFunction(character_blend); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - const int index = uniform(0, character_blend.mesh->vertices.size() - 1); - const Eigen::Vector3 target = projection * targetMesh.vertices[index].homogeneous(); - const Eigen::Vector2 target2d = target.hnormalized(); - errorFunction.addConstraint(index, uniform(0, 1e-2), target2d, projection); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_blend, - Eps(1e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PointTriangleVertexErrorFunction) { - using T = typename TestFixture::Type; - - // The Normal and SymmetricNormal tests are currently failing, for very confusing - // reasons. We get large nonzero values in the Jacobian for the _rigid_ parameters - // when computing them _numerically_, which suggests something funny is going on - // that makes them depend on e.g. a rigid translation of the entire model -- but - // only for Normal/SymmetricNormal constrains. This seems especially weird to me - // for global translations, because the skinning code doesn't even use the translation - // part of the matrix when skinning normals. - // - // For now I'm only using Plane constraints anyway, because letting the Triangle - // in the PointTriangle constraint determine what the normal is seems like the right - // move. At some point we should revisit, either to disable support for Normal constraints - // altogether or figure out where this very weird issue comes from. - - for (const auto& constraintType : { - VertexConstraintType::Position, - VertexConstraintType::Plane, - // VertexConstraintType::Normal, - // VertexConstraintType::SymmetricNormal - }) { - Character character = createTestCharacter(); - character = withTestBlendShapes(character); - - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - SCOPED_TRACE("constraintType: " + std::string(toString(constraintType))); - - PointTriangleVertexErrorFunctionT errorFunction(character, constraintType); - - // Create a simple triangle and a point above it - { - Eigen::Vector3i triangleIndices(0, 1, 2); - Eigen::Vector3 triangleBaryCoords(0.3, 0.3, 0.4); - T depth = 0.1; - T weight = 1.0; - - // Add a constraint - errorFunction.addConstraint(3, triangleIndices, triangleBaryCoords, depth, weight); - } - - { - Eigen::Vector3i triangleIndices(10, 12, 14); - Eigen::Vector3 triangleBaryCoords(0.2, 0.4, 0.4); - T depth = 0.2; - T weight = 0.5; - - // Add a constraint - errorFunction.addConstraint(3, triangleIndices, triangleBaryCoords, depth, weight); - } - - // Set up model parameters and skeleton state - // ModelParametersT modelParams = - // ModelParametersT::Zero(transform.numAllModelParameters()); - const ModelParametersT modelParams = - 0.25 * uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); - - SkeletonStateT state(transform.apply(modelParams), skeleton); - - // Test error calculation - double error = - errorFunction.getError(modelParams, state, MeshStateT(modelParams, state, character)); - EXPECT_GT(error, 0); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character, - Eps(5e-2f, 5e-4), - Eps(1e-6f, 1e-13), - true, - true); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexPositionErrorFunctionFaceParameters) { - using T = typename TestFixture::Type; - - const size_t nConstraints = 10; - - // Face expression blend shapes only - { - const Character character_blend = withTestFaceExpressionBlendShapes(createTestCharacter()); - const ModelParametersT modelParams = 0.25 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - - // TODO: Add Plane, Normal and SymmetricNormal? - for (VertexConstraintType type : {VertexConstraintType::Position}) { - VertexErrorFunctionT errorFunction(character_blend, type); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - errorFunction.addConstraint( - uniform(0, character_blend.mesh->vertices.size() - 1), - uniform(0, 1), - uniform>(0, 1), - uniform>(0.1, 1).normalized()); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_blend, - Eps(5e-2f, 1e-5), - Eps(1e-6f, 5e-16), - true, - false); - } - } - - // Face expression blend shapes plus shape blend shapes - { - const Character character_blend = - withTestBlendShapes(withTestFaceExpressionBlendShapes(createTestCharacter())); - const ModelParametersT modelParams = 0.25 * - uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); - - // TODO: Add Plane, Normal and SymmetricNormal? - for (VertexConstraintType type : {VertexConstraintType::Position}) { - VertexErrorFunctionT errorFunction(character_blend, type); - for (size_t iCons = 0; iCons < nConstraints; ++iCons) { - errorFunction.addConstraint( - uniform(0, character_blend.mesh->vertices.size() - 1), - uniform(0, 1), - uniform>(0, 1), - uniform>(0.1, 1).normalized()); - } - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - modelParams, - character_blend, - Eps(1e-2f, 1e-5), - Eps(1e-6f, 5e-16), - true, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PositionErrorFunctionT errorFunction( - skeleton, character.parameterTransform, GeneralizedLossT::kL2, uniform(0.2, 10)); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PositionL2 Constraint Test"); - std::vector> cl{ - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-6)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorL1_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PositionErrorFunctionT errorFunction( - skeleton, character.parameterTransform, GeneralizedLossT::kL1, uniform(0.5, 2)); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PositionL1 Constraint Test"); - std::vector> cl{ - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(5e-2f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorCauchy_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PositionErrorFunctionT errorFunction( - skeleton, character.parameterTransform, GeneralizedLossT::kCauchy, uniform(0.5, 2)); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PositionCauchy Constraint Test"); - std::vector> cl{ - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(5e-2f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorWelsch_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PositionErrorFunctionT errorFunction( - skeleton, character.parameterTransform, GeneralizedLossT::kWelsch); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PositionWelsch Constraint Test"); - std::vector> cl{ - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(1e-1f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorGeneral_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PositionErrorFunctionT errorFunction( - skeleton, character.parameterTransform, uniform(0.1, 10)); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PositionGeneral Constraint Test"); - std::vector> cl{ - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), - PositionDataT( - uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-1f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(5e-1f, 5e-9), - Eps(1e-6f, 1e-7), - false, - false); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, PlaneErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PlaneErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PlaneL2 Constraint Test"); - std::vector> cl{ - PlaneDataT( - uniform>(0, 1), - uniform>(0.1, 1).normalized(), - uniform(0, 1), - 2, - kTestWeightValue), - PlaneDataT( - uniform>(0, 1), - uniform>(0.1, 1).normalized(), - uniform(0, 1), - 1, - kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-6)); - - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(5e-2f, 1e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, HalfPlaneErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - PlaneErrorFunctionT errorFunction(skeleton, character.parameterTransform, true); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("PlaneL2 Constraint Test"); - std::vector> cl{ - PlaneDataT( - uniform>(0, 1), - uniform>(0.1, 1).normalized(), - uniform(0, 1), - 2, - kTestWeightValue), - PlaneDataT( - uniform>(0, 1), - uniform>(0.1, 1).normalized(), - uniform(0, 1), - 1, - kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-2f, 5e-6)); - - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, OrientationErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - OrientationErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - - { - SCOPED_TRACE("Orientation Constraint Test"); - std::vector> cl{ - OrientationDataT(uniformQuaternion(), uniformQuaternion(), 2, kTestWeightValue), - OrientationDataT(uniformQuaternion(), uniformQuaternion(), 1, kTestWeightValue)}; - errorFunction.setConstraints(std::move(cl)); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(0.03f, 5e-6)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(0.05f, 5e-6), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisDiffErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - Random<> rng(10455); - - // create constraints - FixedAxisDiffErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("FixedAxisDiffL2 Constraint Test"); - std::vector> cl{ - FixedAxisDataT( - rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), - FixedAxisDataT( - rng.uniform>(-1, 1), rng.uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-6)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - rng.uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(9e-2f, 5e-6), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisCosErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - - Random<> rng(43926); - - // create constraints - FixedAxisCosErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("FixedAxisCosL2 Constraint Test"); - std::vector> cl{ - FixedAxisDataT( - rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), - FixedAxisDataT( - rng.uniform>(-1, 1), rng.uniform>(-1, 1), 1, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), - character, - Eps(2e-2f, 1e-4)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - rng.uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(5e-2f, 1e-4), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisAngleErrorL2_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - Random<> rng(37482); - - // create constraints - FixedAxisAngleErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("FixedAxisAngleL2 Constraint Test"); - std::vector> cl{ - FixedAxisDataT( - rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), - // corner case when the angle is close to zero - FixedAxisDataT( - Vector3::UnitY(), Vector3(1e-16, 1 + 1e-16, 1e-16), 1, kTestWeightValue), - FixedAxisDataT(Vector3::UnitX(), Vector3::UnitX(), 2, kTestWeightValue)}; - errorFunction.setConstraints(cl); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 2e-2f, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - rng.uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(2e-1f, 5e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, NormalError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - - // create constraints - NormalErrorFunctionT errorFunction(character.skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("Normal Constraint Test"); - std::vector> cl{ - NormalDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 1, - kTestWeightValue), - NormalDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 2, - kTestWeightValue)}; - errorFunction.setConstraints(cl); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), - character, - 2e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), - character, - 1e-8, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, AimDistError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - AimDistErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("AimDist Constraint Test"); - std::vector> cl{ - AimDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 1, - kTestWeightValue), - AimDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 2, - kTestWeightValue)}; - errorFunction.setConstraints(cl); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 1e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 1e-8, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, AimDirError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - AimDirErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("AimDir Constraint Test"); - std::vector> cl{ - AimDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 1, - kTestWeightValue), - AimDataT( - uniform>(-1, 1), - uniform>(-1, 1), - uniform>(-1, 1), - 2, - kTestWeightValue)}; - errorFunction.setConstraints(cl); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 1e-8, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1, 1); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 3e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, ProjectionError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - ProjectionErrorFunctionT errorFunction(skeleton, character.parameterTransform, 0.01); - Eigen::Matrix4 projection = Eigen::Matrix4::Identity(); - projection(2, 3) = 10; - { - SCOPED_TRACE("Projection Constraint Test"); - // Make a few projection constraints to ensure that at least one of them is active, since - // projections are ignored behind the camera - for (int i = 0; i < 5; ++i) { - errorFunction.addConstraint( - ProjectionConstraintDataT{ - (projection + uniformAffine3().matrix()).topRows(3), - uniform(0, 2), - normal>(Vector3::Zero(), Vector3::Ones()), - uniform(0.1, 2.0), - normal>(Vector2::Zero(), Vector2::Ones())}); - } - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-3, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), 0.0f, 1.0f); - const momentum::SkeletonStateT skelState(transform.apply(parameters), skeleton); - ASSERT_GT(errorFunction.getError(parameters, skelState, MeshStateT()), 0); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 5e-3), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, DistanceConstraint_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // create skeleton and reference values - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // create constraints - DistanceErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 4.5; - { - SCOPED_TRACE("Distance Constraint Test"); - DistanceConstraintDataT constraintData; - constraintData.parent = 1; - constraintData.offset = normal>(0, 1); - constraintData.origin = normal>(0, 1); - constraintData.target = 2.3f; - constraintData.weight = kTestWeightValue; - errorFunction.setConstraints({constraintData}); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 1e-8, - 1e-6, - true, - false); // jacobian test is inaccurate around the corner case - } - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), 1, 0.0f, 1.0f); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 5e-5), Eps(1e-6f, 1e-7)); - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, JointToJointDistanceError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - JointToJointDistanceErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 2.5; - - { - SCOPED_TRACE("JointToJoint Distance Constraint Test"); - - ASSERT_GE(skeleton.joints.size(), 3); - - errorFunction.addConstraint( - 1, - uniform>(-1, 1), - 2, - uniform>(-1, 1), - uniform(0.5, 2.0), - kTestWeightValue); - - errorFunction.addConstraint( - 0, Vector3::Zero(), 2, Vector3::UnitX(), uniform(1.0, 3.0), kTestWeightValue); - - if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 5e-2f); - } else if constexpr (std::is_same_v) { - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - 1e-8, - 1e-6, - true, - false); - } - - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); - } - } - - { - SCOPED_TRACE("JointToJoint Distance Zero Error Test"); - - const ModelParametersT modelParams = - ModelParametersT::Zero(transform.numAllModelParameters()); - const SkeletonStateT skelState(transform.apply(modelParams), skeleton); - - JointToJointDistanceErrorFunctionT errorFunctionZero(skeleton, character.parameterTransform); - - const auto& jointState1 = skelState.jointState[1]; - const auto& jointState2 = skelState.jointState[2]; - - const auto offset1 = uniform>(-1, 1); - const auto offset2 = uniform>(-1, 1); - - const Vector3 worldPos1 = jointState1.transform * offset1; - const Vector3 worldPos2 = jointState2.transform * offset2; - - const T actualDistance = (worldPos1 - worldPos2).norm(); - - errorFunctionZero.addConstraint(1, offset1, 2, offset2, actualDistance, T(1.0)); - - const double error = errorFunctionZero.getError( - modelParams, skelState, MeshStateT(modelParams, skelState, character)); - EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, JointToJointPositionError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - const Character character = createTestCharacter(); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - JointToJointPositionErrorFunctionT errorFunction(skeleton, character.parameterTransform); - const T kTestWeightValue = 2.5; - - { - SCOPED_TRACE("JointToJoint Position Constraint Test"); - - ASSERT_GE(skeleton.joints.size(), 3); - - // Add constraints with random offsets and targets - errorFunction.addConstraint( - 1, // sourceJoint - uniform>(-1, 1), // sourceOffset - 2, // referenceJoint - uniform>(-1, 1), // referenceOffset - uniform>(-1, 1), // target (in reference frame) - kTestWeightValue); - - errorFunction.addConstraint( - 0, - Vector3::Zero(), - 2, - Vector3::UnitX(), - uniform>(-0.5, 0.5), - kTestWeightValue); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(5e-2f, 5e-6)); - for (size_t i = 0; i < 10; i++) { - ModelParametersT parameters = - uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); - TEST_GRADIENT_AND_JACOBIAN( - T, &errorFunction, parameters, character, Eps(6e-2f, 5e-6), Eps(1e-6f, 1e-7)); - } - } - - { - SCOPED_TRACE("JointToJoint Position Zero Error Test"); - - // Test that error is zero when the relative position matches the target - const ModelParametersT modelParams = - ModelParametersT::Zero(transform.numAllModelParameters()); - const SkeletonStateT skelState(transform.apply(modelParams), skeleton); - - JointToJointPositionErrorFunctionT errorFunctionZero(skeleton, character.parameterTransform); - - const auto& srcJointState = skelState.jointState[1]; - const auto& refJointState = skelState.jointState[2]; - - const auto sourceOffset = uniform>(-1, 1); - const auto referenceOffset = uniform>(-1, 1); - - // Compute world positions - const Vector3 srcWorldPos = srcJointState.transform * sourceOffset; - const Vector3 refWorldPos = refJointState.transform * referenceOffset; - - // Compute the relative position in reference frame (this is what the error function computes) - const Vector3 diff = srcWorldPos - refWorldPos; - const Vector3 relativePos = refJointState.transform.toRotationMatrix().transpose() * diff; - - // Set the target to exactly match the current relative position - errorFunctionZero.addConstraint(1, sourceOffset, 2, referenceOffset, relativePos, T(1.0)); - - const double error = errorFunctionZero.getError( - modelParams, skelState, MeshStateT(modelParams, skelState, character)); - EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); - } - - { - SCOPED_TRACE("JointToJoint Position Relative Frame Test"); - - // Test that the constraint correctly transforms to the reference frame - // by verifying that rotating the reference joint doesn't change the error - // when the target is set to the current relative position - - const ModelParametersT modelParams = - 0.5 * uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); - const SkeletonStateT skelState(transform.apply(modelParams), skeleton); - - JointToJointPositionErrorFunctionT errorFunctionRelative( - skeleton, character.parameterTransform); - - const auto& srcJointState = skelState.jointState[1]; - const auto& refJointState = skelState.jointState[2]; - - const Vector3 sourceOffset = Vector3::Zero(); - const Vector3 referenceOffset = Vector3::Zero(); - - // Compute relative position in reference frame - const Vector3 srcWorldPos = srcJointState.transform * sourceOffset; - const Vector3 refWorldPos = refJointState.transform * referenceOffset; - const Vector3 diff = srcWorldPos - refWorldPos; - const Vector3 relativePos = refJointState.transform.toRotationMatrix().transpose() * diff; - - // Add constraint with target matching current relative position - errorFunctionRelative.addConstraint(1, sourceOffset, 2, referenceOffset, relativePos, T(1.0)); - - const double error = errorFunctionRelative.getError( - modelParams, skelState, MeshStateT(modelParams, skelState, character)); - EXPECT_NEAR(error, 0.0, Eps(1e-6f, 1e-12)); - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, HeightError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - SCOPED_TRACE("Height Error Function Test"); - - // Test with blend shapes enabled (so we have shape and scale parameters to work with) - const Character character = withTestBlendShapes(createTestCharacter()); - const ParameterTransformT transform = character.parameterTransform.cast(); - - // Create error function with target height (it automatically uses blend shape and scale - // parameters) - HeightErrorFunctionT errorFunction(character, T(1.8), Eigen::Vector3::UnitY(), 4); - - // Test 1: Validate gradients and Jacobians - { - SCOPED_TRACE("Test standard gradient/Jacobian validation"); - - // Test with zero parameters - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - ModelParametersT::Zero(transform.numAllModelParameters()), - character, - Eps(1e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - - // Test with random parameters - for (size_t i = 0; i < 5; i++) { - ModelParametersT parameters = - 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(5e-2f, 1e-5), - Eps(1e-6f, 1e-14), - true, - false); - } - } - - // Test 2: Verify error does not depend on inactive parameters (pose parameters) - { - SCOPED_TRACE("Test that error does not depend on inactive parameters"); - - // Get the set of active parameters (blend shapes + scale) - const ParameterSet activeParams = transform.getBlendShapeParameters() | - transform.getFaceExpressionParameters() | transform.getScalingParameters(); - - // Create a base parameter set - ModelParametersT baseParameters = - 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); - - // Compute error with base parameters - const SkeletonStateT skelState(transform.apply(baseParameters), character.skeleton); - const double baseError = errorFunction.getError( - baseParameters, skelState, MeshStateT(baseParameters, skelState, character)); - - // Now perturb ONLY inactive parameters and verify error doesn't change - for (size_t i = 0; i < 10; i++) { - ModelParametersT perturbedParameters = baseParameters; - - // Perturb only inactive parameters (pose parameters) - for (Eigen::Index p = 0; p < perturbedParameters.size(); ++p) { - if (!activeParams.test(p)) { - perturbedParameters(p) = uniform(-2.0, 2.0); - } - } - - const SkeletonStateT perturbedSkelState( - transform.apply(perturbedParameters), character.skeleton); - const double perturbedError = errorFunction.getError( - perturbedParameters, - perturbedSkelState, - MeshStateT(perturbedParameters, perturbedSkelState, character)); - - // Error should be identical (or very close) since we only changed inactive parameters - EXPECT_NEAR(perturbedError, baseError, Eps(1e-10f, 1e-15)) - << "Error changed when modifying inactive parameters. " - << "Base error: " << baseError << ", Perturbed error: " << perturbedError; - } - } - - // Test 3: Verify gradients are zero for inactive parameters - { - SCOPED_TRACE("Test that gradients are zero for inactive parameters"); - - // Get the set of active parameters (blend shapes + scale) - const ParameterSet activeParams = transform.getBlendShapeParameters() | - transform.getFaceExpressionParameters() | transform.getScalingParameters(); - - ModelParametersT parameters = - 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); - - const SkeletonStateT skelState(transform.apply(parameters), character.skeleton); - Eigen::VectorX gradient = Eigen::VectorX::Zero(transform.numAllModelParameters()); - - errorFunction.getGradient( - parameters, skelState, MeshStateT(parameters, skelState, character), gradient); - - // Check that gradient is zero for all inactive parameters - for (Eigen::Index p = 0; p < gradient.size(); ++p) { - if (!activeParams.test(p)) { - EXPECT_NEAR(gradient(p), T(0), Eps(1e-10f, 1e-15)) - << "Gradient for inactive parameter " << p << "(" - << character.parameterTransform.name.at(p) << ") is non-zero: " << gradient(p); - } - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, SDFCollisionError_WorldSDF_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // Create test character - const Character character = createTestCharacter(5); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // Create a simple cubic SDF using helper function - auto sdfWorld = std::make_shared>( - axel::SignedDistanceField::createSphere(5.0, Eigen::Vector3(10, 10, 10))); - - auto sdfLocal = std::make_shared>( - axel::SignedDistanceField::createSphere(3.0, Eigen::Vector3(10, 10, 10))); - - // Create SDF collision geometry - SDFCollisionGeometry sdfColliders; - - // Joint-attached SDF for testing both mesh and collider hierarchy gradients - sdfColliders.emplace_back( - SDFCollider{ - TransformT(Eigen::Vector3f(-0.224f, 0.134f, 0.151f)), - 2, // parent joint index - sdfLocal}); - - // Create error function with selected vertices (use first few vertices) - const size_t numTestVertices = std::min(size_t(5), character.mesh->vertices.size()); - std::vector participatingVertices; - std::vector vertexWeights; - - for (size_t i = 0; i < numTestVertices; ++i) { - participatingVertices.push_back(static_cast(i)); - vertexWeights.push_back(uniform(0.1, 2.0)); // Random weights - } - - // don't filter out rest pose intersections because this makes it very hard to construct a - // test case that has actual collisions: - bool filterRestPoseIntersections = false; - SDFCollisionErrorFunctionT errorFunction( - character, sdfColliders, participatingVertices, vertexWeights, filterRestPoseIntersections); - - { - SCOPED_TRACE("SDF Collision Error Test"); - - // Test with zero parameters (bind pose) - const ModelParametersT zeroParams = - ModelParametersT::Zero(transform.numAllModelParameters()); - const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); - const MeshStateT zeroMeshState(zeroParams, zeroState, character); - - // Check that error function is set up correctly - const double zeroError = errorFunction.getError(zeroParams, zeroState, zeroMeshState); - EXPECT_GT(zeroError, 0.0); // Error should be non-negative - - // Test gradients and jacobian for bind pose - // Note: SDF collision gradients have higher error tolerance due to: - // 1. Gauss-Newton approximation (surface point moves as mesh moves, but we treat it as fixed) - // 2. SDF gradient interpolation from discrete grid - // 3. Float precision issues in numerical differentiation for root parameters - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - zeroParams, - character, - Eps(0.1f, 0.04), - Eps(1e-6f, 1e-14), - true, - true); - - // Test with random parameters to create different collision scenarios - for (size_t i = 0; i < 5; i++) { - ModelParametersT parameters = - 0.1 * uniform>(transform.numAllModelParameters(), -1, 1); - - const SkeletonStateT state(transform.apply(parameters), skeleton); - const MeshStateT meshState(parameters, state, character); - const double error = errorFunction.getError(parameters, state, meshState); - EXPECT_GE(error, 0.0); // Error should be non-negative - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(0.25f, 0.04), - Eps(1e-6f, 1e-14), - true, - true); - } - } - - // Test error increases with more penetration - { - SCOPED_TRACE("Penetration Scaling Test"); - - // Create parameters that move joint 1 toward the SDF center - ModelParametersT baseParams = ModelParametersT::Zero(transform.numAllModelParameters()); - - // Find translation parameter for joint 1 - const size_t joint1TranslationBase = 1 * kParametersPerJoint + 1; // Joint 1, translation params - - // Test with increasing translation toward SDF center - double previousError = 0.0; - for (int step = 0; step < 3; ++step) { - ModelParametersT params = baseParams; - params[joint1TranslationBase] += static_cast(0.1); // Move in positive Y direction - - const SkeletonStateT state(transform.apply(params), skeleton); - const MeshStateT meshState(params, state, character); - const double error = errorFunction.getError(params, state, meshState); - - EXPECT_GE(error, 0.0); - - // Error should decrease as we move away from the SDF - if (step > 0) { - EXPECT_LE(error, previousError); - } - - previousError = error; - } - } -} - -TYPED_TEST(Momentum_ErrorFunctionsTest, VertexSDFError_GradientsAndJacobians) { - using T = typename TestFixture::Type; - - // Create test character - const Character character = createTestCharacter(5); - const Skeleton& skeleton = character.skeleton; - const ParameterTransformT transform = character.parameterTransform.cast(); - - // Create a sphere SDF attached to joint 2 - auto sdfLocal = std::make_shared>( - axel::SignedDistanceField::createSphere(3.0, Eigen::Vector3(10, 10, 10))); - - SDFCollider sdfCollider{ - TransformT(Eigen::Vector3f(-0.224f, 0.134f, 0.151f)), - 2, // parent joint index - sdfLocal}; - - VertexSDFErrorFunctionT errorFunction(character, sdfCollider); - - // Add constraints with various target distances - const size_t numTestVertices = std::min(size_t(5), character.mesh->vertices.size()); - for (size_t i = 0; i < numTestVertices; ++i) { - VertexSDFConstraintT constraint; - constraint.vertexIndex = static_cast(i); - constraint.weight = uniform(0.1, 2.0); - - // Vary target distances: surface (0), outside (1), inside (-0.5) - if (i % 3 == 0) { - constraint.targetDistance = T(0); - } else if (i % 3 == 1) { - constraint.targetDistance = T(1); - } else { - constraint.targetDistance = T(-0.5); - } - - errorFunction.addConstraint(constraint); - } - - EXPECT_EQ(errorFunction.numConstraints(), numTestVertices); - - { - SCOPED_TRACE("Vertex SDF Error Test - Zero Parameters"); - - const ModelParametersT zeroParams = - ModelParametersT::Zero(transform.numAllModelParameters()); - const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); - const MeshStateT zeroMeshState(zeroParams, zeroState, character); - - const double zeroError = errorFunction.getError(zeroParams, zeroState, zeroMeshState); - EXPECT_GE(zeroError, 0.0); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - zeroParams, - character, - Eps(0.1f, 0.04), - Eps(1e-6f, 1e-14), - true, - true); - } - - { - SCOPED_TRACE("Vertex SDF Error Test - Random Parameters"); - - for (size_t i = 0; i < 5; i++) { - ModelParametersT parameters = - 0.1 * uniform>(transform.numAllModelParameters(), -1, 1); - - const SkeletonStateT state(transform.apply(parameters), skeleton); - const MeshStateT meshState(parameters, state, character); - const double error = errorFunction.getError(parameters, state, meshState); - EXPECT_GE(error, 0.0); - - TEST_GRADIENT_AND_JACOBIAN( - T, - &errorFunction, - parameters, - character, - Eps(0.3f, 0.04), - Eps(1e-6f, 1e-14), - true, - true); - } - } - - // Test that clearing constraints results in zero error - { - SCOPED_TRACE("Clear Constraints Test"); - - errorFunction.clearConstraints(); - EXPECT_EQ(errorFunction.numConstraints(), 0); - - const ModelParametersT zeroParams = - ModelParametersT::Zero(transform.numAllModelParameters()); - const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); - const MeshStateT zeroMeshState(zeroParams, zeroState, character); - - const double error = errorFunction.getError(zeroParams, zeroState, zeroMeshState); - EXPECT_EQ(error, 0.0); - } -} diff --git a/momentum/test/character_solver/geometric_error_function_test.cpp b/momentum/test/character_solver/geometric_error_function_test.cpp new file mode 100644 index 0000000000..75c335b694 --- /dev/null +++ b/momentum/test/character_solver/geometric_error_function_test.cpp @@ -0,0 +1,530 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/distance_error_function.h" +#include "momentum/character_solver/height_error_function.h" +#include "momentum/character_solver/joint_to_joint_distance_error_function.h" +#include "momentum/character_solver/joint_to_joint_position_error_function.h" +#include "momentum/character_solver/plane_error_function.h" +#include "momentum/character_solver/projection_error_function.h" +#include "momentum/math/random.h" +#include "momentum/math/utility.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, PlaneErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PlaneErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PlaneL2 Constraint Test"); + std::vector> cl{ + PlaneDataT( + uniform>(0, 1), + uniform>(0.1, 1).normalized(), + uniform(0, 1), + 2, + kTestWeightValue), + PlaneDataT( + uniform>(0, 1), + uniform>(0.1, 1).normalized(), + uniform(0, 1), + 1, + kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-6)); + + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(5e-2f, 1e-5), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, HalfPlaneErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PlaneErrorFunctionT errorFunction(skeleton, character.parameterTransform, true); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PlaneL2 Constraint Test"); + std::vector> cl{ + PlaneDataT( + uniform>(0, 1), + uniform>(0.1, 1).normalized(), + uniform(0, 1), + 2, + kTestWeightValue), + PlaneDataT( + uniform>(0, 1), + uniform>(0.1, 1).normalized(), + uniform(0, 1), + 1, + kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-2f, 5e-6)); + + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, ProjectionError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + ProjectionErrorFunctionT errorFunction(skeleton, character.parameterTransform, 0.01); + Eigen::Matrix4 projection = Eigen::Matrix4::Identity(); + projection(2, 3) = 10; + { + SCOPED_TRACE("Projection Constraint Test"); + // Make a few projection constraints to ensure that at least one of them is active, since + // projections are ignored behind the camera + for (int i = 0; i < 5; ++i) { + errorFunction.addConstraint( + ProjectionConstraintDataT{ + (projection + uniformAffine3().matrix()).topRows(3), + uniform(0, 2), + normal>(Vector3::Zero(), Vector3::Ones()), + uniform(0.1, 2.0), + normal>(Vector2::Zero(), Vector2::Ones())}); + } + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-3, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), 0.0f, 1.0f); + const momentum::SkeletonStateT skelState(transform.apply(parameters), skeleton); + ASSERT_GT(errorFunction.getError(parameters, skelState, MeshStateT()), 0); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 5e-3), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, DistanceConstraint_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + DistanceErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("Distance Constraint Test"); + DistanceConstraintDataT constraintData; + constraintData.parent = 1; + constraintData.offset = normal>(0, 1); + constraintData.origin = normal>(0, 1); + constraintData.target = 2.3f; + constraintData.weight = kTestWeightValue; + errorFunction.setConstraints({constraintData}); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 1e-8, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), 1, 0.0f, 1.0f); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 5e-5), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, JointToJointDistanceError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + JointToJointDistanceErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 2.5; + + { + SCOPED_TRACE("JointToJoint Distance Constraint Test"); + + ASSERT_GE(skeleton.joints.size(), 3); + + errorFunction.addConstraint( + 1, + uniform>(-1, 1), + 2, + uniform>(-1, 1), + uniform(0.5, 2.0), + kTestWeightValue); + + errorFunction.addConstraint( + 0, Vector3::Zero(), 2, Vector3::UnitX(), uniform(1.0, 3.0), kTestWeightValue); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 1e-8, + 1e-6, + true, + false); + } + + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); + } + } + + { + SCOPED_TRACE("JointToJoint Distance Zero Error Test"); + + const ModelParametersT modelParams = + ModelParametersT::Zero(transform.numAllModelParameters()); + const SkeletonStateT skelState(transform.apply(modelParams), skeleton); + + JointToJointDistanceErrorFunctionT errorFunctionZero(skeleton, character.parameterTransform); + + const auto& jointState1 = skelState.jointState[1]; + const auto& jointState2 = skelState.jointState[2]; + + const auto offset1 = uniform>(-1, 1); + const auto offset2 = uniform>(-1, 1); + + const Vector3 worldPos1 = jointState1.transform * offset1; + const Vector3 worldPos2 = jointState2.transform * offset2; + + const T actualDistance = (worldPos1 - worldPos2).norm(); + + errorFunctionZero.addConstraint(1, offset1, 2, offset2, actualDistance, T(1.0)); + + const double error = errorFunctionZero.getError( + modelParams, skelState, MeshStateT(modelParams, skelState, character)); + EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, JointToJointPositionError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + JointToJointPositionErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 2.5; + + { + SCOPED_TRACE("JointToJoint Position Constraint Test"); + + ASSERT_GE(skeleton.joints.size(), 3); + + // Add constraints with random offsets and targets + errorFunction.addConstraint( + 1, // sourceJoint + uniform>(-1, 1), // sourceOffset + 2, // referenceJoint + uniform>(-1, 1), // referenceOffset + uniform>(-1, 1), // target (in reference frame) + kTestWeightValue); + + errorFunction.addConstraint( + 0, + Vector3::Zero(), + 2, + Vector3::UnitX(), + uniform>(-0.5, 0.5), + kTestWeightValue); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-6)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(6e-2f, 5e-6), Eps(1e-6f, 1e-7)); + } + } + + { + SCOPED_TRACE("JointToJoint Position Zero Error Test"); + + // Test that error is zero when the relative position matches the target + const ModelParametersT modelParams = + ModelParametersT::Zero(transform.numAllModelParameters()); + const SkeletonStateT skelState(transform.apply(modelParams), skeleton); + + JointToJointPositionErrorFunctionT errorFunctionZero(skeleton, character.parameterTransform); + + const auto& srcJointState = skelState.jointState[1]; + const auto& refJointState = skelState.jointState[2]; + + const auto sourceOffset = uniform>(-1, 1); + const auto referenceOffset = uniform>(-1, 1); + + // Compute world positions + const Vector3 srcWorldPos = srcJointState.transform * sourceOffset; + const Vector3 refWorldPos = refJointState.transform * referenceOffset; + + // Compute the relative position in reference frame (this is what the error function computes) + const Vector3 diff = srcWorldPos - refWorldPos; + const Vector3 relativePos = refJointState.transform.toRotationMatrix().transpose() * diff; + + // Set the target to exactly match the current relative position + errorFunctionZero.addConstraint(1, sourceOffset, 2, referenceOffset, relativePos, T(1.0)); + + const double error = errorFunctionZero.getError( + modelParams, skelState, MeshStateT(modelParams, skelState, character)); + EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); + } + + { + SCOPED_TRACE("JointToJoint Position Relative Frame Test"); + + // Test that the constraint correctly transforms to the reference frame + // by verifying that rotating the reference joint doesn't change the error + // when the target is set to the current relative position + + const ModelParametersT modelParams = + 0.5 * uniform>(transform.numAllModelParameters(), -1.0f, 1.0f); + const SkeletonStateT skelState(transform.apply(modelParams), skeleton); + + JointToJointPositionErrorFunctionT errorFunctionRelative( + skeleton, character.parameterTransform); + + const auto& srcJointState = skelState.jointState[1]; + const auto& refJointState = skelState.jointState[2]; + + const Vector3 sourceOffset = Vector3::Zero(); + const Vector3 referenceOffset = Vector3::Zero(); + + // Compute relative position in reference frame + const Vector3 srcWorldPos = srcJointState.transform * sourceOffset; + const Vector3 refWorldPos = refJointState.transform * referenceOffset; + const Vector3 diff = srcWorldPos - refWorldPos; + const Vector3 relativePos = refJointState.transform.toRotationMatrix().transpose() * diff; + + // Add constraint with target matching current relative position + errorFunctionRelative.addConstraint(1, sourceOffset, 2, referenceOffset, relativePos, T(1.0)); + + const double error = errorFunctionRelative.getError( + modelParams, skelState, MeshStateT(modelParams, skelState, character)); + EXPECT_NEAR(error, 0.0, Eps(1e-6f, 1e-12)); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, HeightError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + SCOPED_TRACE("Height Error Function Test"); + + // Test with blend shapes enabled (so we have shape and scale parameters to work with) + const Character character = withTestBlendShapes(createTestCharacter()); + const ParameterTransformT transform = character.parameterTransform.cast(); + + // Create error function with target height (it automatically uses blend shape and scale + // parameters) + HeightErrorFunctionT errorFunction(character, T(1.8), Eigen::Vector3::UnitY(), 4); + + // Test 1: Validate gradients and Jacobians + { + SCOPED_TRACE("Test standard gradient/Jacobian validation"); + + // Test with zero parameters + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + + // Test with random parameters + for (size_t i = 0; i < 5; i++) { + ModelParametersT parameters = + 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(5e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + } + } + + // Test 2: Verify error does not depend on inactive parameters (pose parameters) + { + SCOPED_TRACE("Test that error does not depend on inactive parameters"); + + // Get the set of active parameters (blend shapes + scale) + const ParameterSet activeParams = transform.getBlendShapeParameters() | + transform.getFaceExpressionParameters() | transform.getScalingParameters(); + + // Create a base parameter set + ModelParametersT baseParameters = + 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); + + // Compute error with base parameters + const SkeletonStateT skelState(transform.apply(baseParameters), character.skeleton); + const double baseError = errorFunction.getError( + baseParameters, skelState, MeshStateT(baseParameters, skelState, character)); + + // Now perturb ONLY inactive parameters and verify error doesn't change + for (size_t i = 0; i < 10; i++) { + ModelParametersT perturbedParameters = baseParameters; + + // Perturb only inactive parameters (pose parameters) + for (Eigen::Index p = 0; p < perturbedParameters.size(); ++p) { + if (!activeParams.test(p)) { + perturbedParameters(p) = uniform(-2.0, 2.0); + } + } + + const SkeletonStateT perturbedSkelState( + transform.apply(perturbedParameters), character.skeleton); + const double perturbedError = errorFunction.getError( + perturbedParameters, + perturbedSkelState, + MeshStateT(perturbedParameters, perturbedSkelState, character)); + + // Error should be identical (or very close) since we only changed inactive parameters + EXPECT_NEAR(perturbedError, baseError, Eps(1e-10f, 1e-15)) + << "Error changed when modifying inactive parameters. " + << "Base error: " << baseError << ", Perturbed error: " << perturbedError; + } + } + + // Test 3: Verify gradients are zero for inactive parameters + { + SCOPED_TRACE("Test that gradients are zero for inactive parameters"); + + // Get the set of active parameters (blend shapes + scale) + const ParameterSet activeParams = transform.getBlendShapeParameters() | + transform.getFaceExpressionParameters() | transform.getScalingParameters(); + + ModelParametersT parameters = + 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); + + const SkeletonStateT skelState(transform.apply(parameters), character.skeleton); + Eigen::VectorX gradient = Eigen::VectorX::Zero(transform.numAllModelParameters()); + + errorFunction.getGradient( + parameters, skelState, MeshStateT(parameters, skelState, character), gradient); + + // Check that gradient is zero for all inactive parameters + for (Eigen::Index p = 0; p < gradient.size(); ++p) { + if (!activeParams.test(p)) { + EXPECT_NEAR(gradient(p), T(0), Eps(1e-10f, 1e-15)) + << "Gradient for inactive parameter " << p << "(" + << character.parameterTransform.name.at(p) << ") is non-zero: " << gradient(p); + } + } + } +} diff --git a/momentum/test/character_solver/limit_error_function_test.cpp b/momentum/test/character_solver/limit_error_function_test.cpp new file mode 100644 index 0000000000..8969a5d578 --- /dev/null +++ b/momentum/test/character_solver/limit_error_function_test.cpp @@ -0,0 +1,299 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/limit_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, LimitError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + LimitErrorFunctionT errorFunction(skeleton, character.parameterTransform); + + // TODO: None of these work right at the moment due to precision issues with numerical gradients, + // need to fix code to use double + { + SCOPED_TRACE("Limit MinMax Test"); + ParameterLimit limit; + limit.type = MinMax; + limit.weight = 1.0; + limit.data.minMax.limits = Vector2f(-0.1, 0.1); + limit.data.minMax.parameterIndex = 0; + errorFunction.setLimits({limit}); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-7f, 1e-15)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 1e-11)); + } + } + + { + SCOPED_TRACE("Limit MinMax Joint Test"); + ParameterLimit limit; + limit.type = MinMaxJoint; + limit.weight = 1.0; + limit.data.minMaxJoint.limits = Vector2f(-0.1, 0.1); + limit.data.minMaxJoint.jointIndex = 2; + limit.data.minMaxJoint.jointParameter = 5; + errorFunction.setLimits({limit}); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-7f, 1e-15)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-3f, 1e-10)); + } + } + + { + SCOPED_TRACE("Limit LinearTest"); + ParameterLimit limit; + limit.type = Linear; + limit.weight = 1.5; + limit.data.linear.referenceIndex = 0; + limit.data.linear.targetIndex = 5; + limit.data.linear.scale = 0.25; + limit.data.linear.offset = 0.25; + errorFunction.setLimits({limit}); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-3f, 5e-12)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(2e-2f, 1e-10)); + } + } + + { + SCOPED_TRACE("Limit PiecewiseLinearTest"); + ParameterLimits limits; + + ParameterLimit limit; + limit.type = LimitType::Linear; + limit.data.linear.referenceIndex = 0; + limit.data.linear.targetIndex = 5; + limit.weight = 0.5f; + + { + ParameterLimit cur = limit; + cur.data.linear.scale = -1.0f; + cur.data.linear.offset = 3.0f; + cur.data.linear.rangeMin = -FLT_MAX; + cur.data.linear.rangeMax = -3.0f; + limits.push_back(cur); + } + + { + ParameterLimit cur = limit; + cur.data.linear.scale = 1.0f; + cur.data.linear.offset = -3.0f; + cur.data.linear.rangeMin = -3.0f; + cur.data.linear.rangeMax = FLT_MAX; + limits.push_back(cur); + } + + errorFunction.setLimits(limits); + + // Verify that gradients are ok on either side of the first-derivative discontinuity: + ModelParametersT parametersBefore = VectorX::Zero(transform.numAllModelParameters()); + parametersBefore(limit.data.linear.targetIndex) = -3.01f; + const SkeletonStateT skelStateBefore( + character.parameterTransform.cast().apply(parametersBefore), character.skeleton); + Eigen::VectorX gradBefore = VectorX::Zero(transform.numAllModelParameters()); + errorFunction.getGradient(parametersBefore, skelStateBefore, MeshStateT(), gradBefore); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parametersBefore, character, Eps(1e-3f, 1e-10)); + + ModelParametersT parametersAfter = VectorX::Zero(transform.numAllModelParameters()); + parametersAfter(limit.data.linear.targetIndex) = -2.99f; + const SkeletonStateT skelStateAfter( + character.parameterTransform.cast().apply(parametersAfter), character.skeleton); + Eigen::VectorX gradAfter = VectorX::Zero(transform.numAllModelParameters()); + errorFunction.getGradient(parametersAfter, skelStateAfter, MeshStateT(), gradAfter); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parametersAfter, character, Eps(1e-3f, 1e-10)); + + // Gradient won't be the same at the point 3.0: + ModelParametersT parametersMid = VectorX::Zero(transform.numAllModelParameters()); + parametersMid(limit.data.linear.targetIndex) = -3.0f; + const SkeletonStateT skelStateMid( + character.parameterTransform.cast().apply(parametersMid), character.skeleton); + + // Verify that the error is C0 continuous: + const float errorBefore = + errorFunction.getError(parametersBefore, skelStateBefore, MeshStateT()); + const float errorMid = errorFunction.getError(parametersMid, skelStateMid, MeshStateT()); + const float errorAfter = + errorFunction.getError(parametersAfter, skelStateAfter, MeshStateT()); + + ASSERT_NEAR(errorBefore, errorMid, 0.03f); + ASSERT_NEAR(errorMid, errorAfter, 0.03f); + } + + { + SCOPED_TRACE("LimitJoint PiecewiseLinearTest"); + ParameterLimits limits; + + ParameterLimit limit; + limit.type = LimitType::LinearJoint; + limit.data.linearJoint.referenceJointIndex = 0; + limit.data.linearJoint.referenceJointParameter = 2; + limit.data.linearJoint.targetJointIndex = 1; + limit.data.linearJoint.targetJointParameter = 5; + limit.weight = 0.75f; + + { + ParameterLimit cur = limit; + cur.data.linearJoint.scale = 1.0f; + cur.data.linearJoint.offset = -4.0f; + cur.data.linearJoint.rangeMin = -FLT_MAX; + cur.data.linearJoint.rangeMax = 0.0f; + limits.push_back(cur); + } + + { + ParameterLimit cur = limit; + cur.data.linearJoint.scale = -1.0f; + cur.data.linearJoint.offset = -4.0f; + cur.data.linearJoint.rangeMin = 0.0f; + cur.data.linearJoint.rangeMax = 2.0; + limits.push_back(cur); + } + + { + ParameterLimit cur = limit; + cur.data.linearJoint.scale = 1.0f; + cur.data.linearJoint.offset = 0.0f; + cur.data.linearJoint.rangeMin = 2.0f; + cur.data.linearJoint.rangeMax = FLT_MAX; + limits.push_back(cur); + } + + errorFunction.setLimits(limits); + + auto rz_param = character.parameterTransform.getParameterIdByName("shared_rz"); + ASSERT_NE(rz_param, momentum::kInvalidIndex); + + for (const auto& testPos : {-4.0, 0.0, 4.0}) { + // Verify that gradients are ok on either side of the first-derivative discontinuity: + ModelParametersT parametersBefore = VectorX::Zero(transform.numAllModelParameters()); + parametersBefore(rz_param) = testPos - 0.001f; + const SkeletonStateT skelStateBefore( + character.parameterTransform.cast().apply(parametersBefore), character.skeleton); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parametersBefore, character, Eps(5e-2f, 1e-10)); + + ModelParametersT parametersAfter = VectorX::Zero(transform.numAllModelParameters()); + parametersAfter(rz_param) = testPos + 0.001f; + const SkeletonStateT skelStateAfter( + character.parameterTransform.cast().apply(parametersAfter), character.skeleton); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parametersAfter, character, Eps(5e-2f, 1e-10)); + + // Verify that the error is C0 continuous: + const float errorBefore = + errorFunction.getError(parametersBefore, skelStateBefore, MeshStateT()); + const float errorAfter = + errorFunction.getError(parametersAfter, skelStateAfter, MeshStateT()); + ASSERT_NEAR(errorAfter, errorBefore, 0.03f); + + // Make sure the parameter actually varies: + const auto targetJointParameter = + limit.data.linearJoint.targetJointIndex * kParametersPerJoint + + limit.data.linearJoint.targetJointParameter; + EXPECT_GT( + std::abs( + skelStateBefore.jointParameters[targetJointParameter] - + skelStateAfter.jointParameters[targetJointParameter]), + 0.0005f); + } + } + + { + SCOPED_TRACE("Limit HalfPlaneTest"); + + ParameterLimit limit; + limit.type = HalfPlane; + limit.weight = 1.0; + limit.data.halfPlane.param1 = 0; + limit.data.halfPlane.param2 = 2; + limit.data.halfPlane.normal = Eigen::Vector2f(1, -1).normalized(); + limit.data.halfPlane.offset = 0.5f; + errorFunction.setLimits({limit}); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-3f, 5e-12)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(2e-2f, 1e-10)); + } + } + + //{ + // SCOPED_TRACE("Limit Ellipsoid Test"); + // limit.type = ELLIPSOID; + // limit.weight = 1.0f; + // limit.data.ellipsoid.parent = 2; + // limit.data.ellipsoid.ellipsoidParent = 0; + // limit.data.ellipsoid.offset = Vector3f(0, -1, 0); + // limit.data.ellipsoid.ellipsoid = Affine3f::Identity(); + // limit.data.ellipsoid.ellipsoid.translation() = Vector3f(0.5f, 0.5f, 0.5f); + // limit.data.ellipsoid.ellipsoid.linear() = (Quaternionf(Eigen::AngleAxisf(0.1f, + // Vector3f::UnitZ())) * + // Quaternionf(Eigen::AngleAxisf(0.2f, + // Vector3f::UnitY())) * + // Quaternionf(Eigen::AngleAxisf(0.3f, + // Vector3f::UnitX()))).toRotationMatrix() * + // Scaling(2.0f, 1.5f, 0.5f); + // limit.data.ellipsoid.ellipsoidInv = limit.data.ellipsoid.ellipsoid.inverse(); + + // errorFunction.setLimits(lm); + // parameters.setZero(); + // testGradientAndJacobian(&errorFunction, parameters, skeleton, transform); + // for (size_t i = 0; i < 3; i++) + // { + // parameters = VectorXd::Random(transform.numAllModelParameters()); + // testGradientAndJacobian(&errorFunction, parameters, skeleton, transform); + // } + //} +} diff --git a/momentum/test/character_solver/orientation_error_function_test.cpp b/momentum/test/character_solver/orientation_error_function_test.cpp new file mode 100644 index 0000000000..d67d23c965 --- /dev/null +++ b/momentum/test/character_solver/orientation_error_function_test.cpp @@ -0,0 +1,346 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/aim_error_function.h" +#include "momentum/character_solver/fixed_axis_error_function.h" +#include "momentum/character_solver/normal_error_function.h" +#include "momentum/character_solver/orientation_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, OrientationErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + OrientationErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + + { + SCOPED_TRACE("Orientation Constraint Test"); + std::vector> cl{ + OrientationDataT(uniformQuaternion(), uniformQuaternion(), 2, kTestWeightValue), + OrientationDataT(uniformQuaternion(), uniformQuaternion(), 1, kTestWeightValue)}; + errorFunction.setConstraints(std::move(cl)); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(0.03f, 5e-6)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(0.05f, 5e-6), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisDiffErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + Random<> rng(10455); + + // create constraints + FixedAxisDiffErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("FixedAxisDiffL2 Constraint Test"); + std::vector> cl{ + FixedAxisDataT( + rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), + FixedAxisDataT( + rng.uniform>(-1, 1), rng.uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-6)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + rng.uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(9e-2f, 5e-6), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisCosErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + + Random<> rng(43926); + + // create constraints + FixedAxisCosErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("FixedAxisCosL2 Constraint Test"); + std::vector> cl{ + FixedAxisDataT( + rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), + FixedAxisDataT( + rng.uniform>(-1, 1), rng.uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), + character, + Eps(2e-2f, 1e-4)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + rng.uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(5e-2f, 1e-4), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, FixedAxisAngleErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + Random<> rng(37482); + + // create constraints + FixedAxisAngleErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("FixedAxisAngleL2 Constraint Test"); + std::vector> cl{ + FixedAxisDataT( + rng.uniform>(-1, 1), rng.uniform>(-1, 1), 2, kTestWeightValue), + // corner case when the angle is close to zero + FixedAxisDataT( + Vector3::UnitY(), Vector3(1e-16, 1 + 1e-16, 1e-16), 1, kTestWeightValue), + FixedAxisDataT(Vector3::UnitX(), Vector3::UnitX(), 2, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 2e-2f, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + rng.uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(2e-1f, 5e-5), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, NormalError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + + // create constraints + NormalErrorFunctionT errorFunction(character.skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("Normal Constraint Test"); + std::vector> cl{ + NormalDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 1, + kTestWeightValue), + NormalDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 2, + kTestWeightValue)}; + errorFunction.setConstraints(cl); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), + character, + 2e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()), + character, + 1e-8, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, AimDistError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + AimDistErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("AimDist Constraint Test"); + std::vector> cl{ + AimDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 1, + kTestWeightValue), + AimDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 2, + kTestWeightValue)}; + errorFunction.setConstraints(cl); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 1e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 1e-8, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 2e-5), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, AimDirError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + AimDirErrorFunctionT errorFunction(skeleton, character.parameterTransform); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("AimDir Constraint Test"); + std::vector> cl{ + AimDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 1, + kTestWeightValue), + AimDataT( + uniform>(-1, 1), + uniform>(-1, 1), + uniform>(-1, 1), + 2, + kTestWeightValue)}; + errorFunction.setConstraints(cl); + + if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 5e-2f); + } else if constexpr (std::is_same_v) { + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + 1e-8, + 1e-6, + true, + false); // jacobian test is inaccurate around the corner case + } + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(1e-1f, 3e-5), Eps(1e-6f, 1e-7)); + } + } +} diff --git a/momentum/test/character_solver/position_error_function_test.cpp b/momentum/test/character_solver/position_error_function_test.cpp new file mode 100644 index 0000000000..d447135a5f --- /dev/null +++ b/momentum/test/character_solver/position_error_function_test.cpp @@ -0,0 +1,242 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/position_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorL2_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PositionErrorFunctionT errorFunction( + skeleton, character.parameterTransform, GeneralizedLossT::kL2, uniform(0.2, 10)); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PositionL2 Constraint Test"); + std::vector> cl{ + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-6)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6), Eps(1e-6f, 1e-7)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorL1_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PositionErrorFunctionT errorFunction( + skeleton, character.parameterTransform, GeneralizedLossT::kL1, uniform(0.5, 2)); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PositionL1 Constraint Test"); + std::vector> cl{ + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(5e-2f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorCauchy_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PositionErrorFunctionT errorFunction( + skeleton, character.parameterTransform, GeneralizedLossT::kCauchy, uniform(0.5, 2)); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PositionCauchy Constraint Test"); + std::vector> cl{ + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(5e-2f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorWelsch_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PositionErrorFunctionT errorFunction( + skeleton, character.parameterTransform, GeneralizedLossT::kWelsch); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PositionWelsch Constraint Test"); + std::vector> cl{ + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(5e-2f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(1e-1f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PositionErrorGeneral_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PositionErrorFunctionT errorFunction( + skeleton, character.parameterTransform, uniform(0.1, 10)); + const T kTestWeightValue = 4.5; + { + SCOPED_TRACE("PositionGeneral Constraint Test"); + std::vector> cl{ + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 2, kTestWeightValue), + PositionDataT( + uniform>(-1, 1), uniform>(-1, 1), 1, kTestWeightValue)}; + errorFunction.setConstraints(cl); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-1f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(5e-1f, 5e-9), + Eps(1e-6f, 1e-7), + false, + false); + } + } +} diff --git a/momentum/test/character_solver/skinned_locator_error_function_test.cpp b/momentum/test/character_solver/skinned_locator_error_function_test.cpp new file mode 100644 index 0000000000..1e7ce62996 --- /dev/null +++ b/momentum/test/character_solver/skinned_locator_error_function_test.cpp @@ -0,0 +1,386 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/linear_skinning.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character/skin_weights.h" +#include "momentum/character_solver/position_error_function.h" +#include "momentum/character_solver/skinned_locator_error_function.h" +#include "momentum/character_solver/skinned_locator_triangle_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +namespace { + +Character getSkinnedLocatorTestCharacter() { + Character character = withTestBlendShapes(createTestCharacter(4)); + + std::vector activeLocators(character.skinnedLocators.size(), true); + activeLocators[1] = false; + const auto [transform, limits] = addSkinnedLocatorParameters( + character.parameterTransform, character.parameterLimits, activeLocators); + + return { + character.skeleton, + transform, + limits, + character.locators, + character.mesh.get(), + character.skinWeights.get(), + character.collision.get(), + character.poseShapes.get(), + character.blendShape, + character.faceExpressionBlendShape, + character.name, + character.inverseBindPose, + character.skinnedLocators}; +} + +} // namespace + +TYPED_TEST(Momentum_ErrorFunctionsTest, SkinnedLocatorError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // Create a test character with skinned locators + const Character character = getSkinnedLocatorTestCharacter(); + const ParameterTransformT transform = character.parameterTransform.cast(); + // Verify that the character has skinned locators + ASSERT_GT(character.skinnedLocators.size(), 2); + + // Create the error function + SkinnedLocatorErrorFunctionT errorFunction(character); + + // Add constraints for some of the skinned locators + const T kTestWeightValue = 4.5; + for (int i = 0; i < std::min(3, static_cast(character.skinnedLocators.size())); ++i) { + errorFunction.addConstraint( + i, kTestWeightValue, uniform>(-1, 1)); // Random target position + } + + // Test with random parameters + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-2f, 5e-6)); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PointTriangleSkinnedLocatorError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // Create a test character with skinned locators + const Character character = getSkinnedLocatorTestCharacter(); + const ParameterTransformT transform = character.parameterTransform.cast(); + + // Verify that the character has skinned locators + ASSERT_GT(character.skinnedLocators.size(), 0); + + // Create the error function with Plane constraint + for (const auto vertexConstraintType : + {VertexConstraintType::Plane, VertexConstraintType::Position}) { + SkinnedLocatorTriangleErrorFunctionT errorFunction(character, vertexConstraintType); + + // Add constraints for some of the skinned locators + const T kTestWeightValue = 5.0; + for (int i = 0; i < character.skinnedLocators.size(); ++i) { + // Create a simple triangle constraint + Eigen::Vector3i triangleIndices = + character.mesh->faces[uniform(0, character.mesh->faces.size() - 1)]; + auto triangleBaryCoords = uniform>(0, 1); + triangleBaryCoords /= triangleBaryCoords.sum(); + T depth = 0.5; + + errorFunction.addConstraint(i, triangleIndices, triangleBaryCoords, depth, kTestWeightValue); + } + + // Test with random parameters + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + 0.25 * uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(0.03, 1e-3), true, true); + } + } +} + +// Test that sliding constraints correctly slide to nearest candidate triangle +TYPED_TEST(Momentum_ErrorFunctionsTest, SkinnedLocatorTriangle_SlidingConstraints) { + using T = typename TestFixture::Type; + + // Create a test character with skinned locators + const Character character = getSkinnedLocatorTestCharacter(); + const ParameterTransformT transform = character.parameterTransform.cast(); + const Mesh& mesh = *character.mesh; + + ASSERT_GT(character.skinnedLocators.size(), 0); + ASSERT_GT(mesh.faces.size(), 3); // Need at least a few triangles + + // Test with Position constraint type (similar results expected for Plane) + SkinnedLocatorTriangleErrorFunctionT errorFunction(character, VertexConstraintType::Position); + + // Pick three triangles for testing: initial triangle and two candidates + const size_t initialTriIdx = 0; + const size_t candidateTriIdx1 = 1; + const size_t candidateTriIdx2 = 2; + const size_t excludedTriIdx = 3; // Not in candidate list + + const Eigen::Vector3i initialTriIndices = mesh.faces[initialTriIdx]; + const Eigen::Vector3i candidateTri1Indices = mesh.faces[candidateTriIdx1]; + const Eigen::Vector3i candidateTri2Indices = mesh.faces[candidateTriIdx2]; + const Eigen::Vector3i excludedTriIndices = mesh.faces[excludedTriIdx]; + + // Create constraint with candidate triangles + SkinnedLocatorTriangleConstraintT constraint; + constraint.locatorIndex = 0; + constraint.tgtTriangleIndices = initialTriIndices; + constraint.tgtTriangleBaryCoords = Eigen::Vector3(T(1.0 / 3.0), T(1.0 / 3.0), T(1.0 / 3.0)); + constraint.depth = T(0); + constraint.weight = T(1); + + // Add candidate triangles (including the initial one) + CandidateTriangle candidate0; + candidate0.triangleIdx = initialTriIdx; + candidate0.vertexIndices = initialTriIndices; + constraint.candidateTriangles.push_back(candidate0); + + CandidateTriangle candidate1; + candidate1.triangleIdx = candidateTriIdx1; + candidate1.vertexIndices = candidateTri1Indices; + constraint.candidateTriangles.push_back(candidate1); + + CandidateTriangle candidate2; + candidate2.triangleIdx = candidateTriIdx2; + candidate2.vertexIndices = candidateTri2Indices; + constraint.candidateTriangles.push_back(candidate2); + + // Set the constraint + std::vector> constraints = {constraint}; + errorFunction.setConstraints(constraints); + + // Create parameters and state first + ModelParametersT params = ModelParametersT::Zero(transform.numAllModelParameters()); + SkeletonStateT state(transform.apply(params), character.skeleton); + + // Set up mesh state using the MeshStateT constructor + MeshStateT meshState(params, state, character); + + // Helper to compute centroid of a triangle + auto getTriangleCentroid = [&mesh](size_t triIdx) -> Eigen::Vector3 { + const Eigen::Vector3i& face = mesh.faces[triIdx]; + return (mesh.vertices[face(0)].template cast() + mesh.vertices[face(1)].template cast() + + mesh.vertices[face(2)].template cast()) / + T(3); + }; + + // Get centroids for testing + const Eigen::Vector3 initialCentroid = getTriangleCentroid(initialTriIdx); + const Eigen::Vector3 candidate1Centroid = getTriangleCentroid(candidateTriIdx1); + const Eigen::Vector3 candidate2Centroid = getTriangleCentroid(candidateTriIdx2); + const Eigen::Vector3 excludedCentroid = getTriangleCentroid(excludedTriIdx); + + // Get the skinned locator parameter index + const SkinnedLocator& locator = character.skinnedLocators[0]; + int locatorParamIdx = -1; + if (character.parameterTransform.skinnedLocatorParameters.size() > 0) { + locatorParamIdx = character.parameterTransform.skinnedLocatorParameters[0]; + } + ASSERT_GE(locatorParamIdx, 0); // Ensure the locator has parameters + + // Test 1: Position locator at initial triangle centroid - error should be near zero + { + Eigen::Vector3 offset = initialCentroid - locator.position.template cast(); + params.v.template segment<3>(locatorParamIdx) = offset; + double errorAtInitial = errorFunction.getError(params, state, meshState); + // Error should be small (locator is at the target) + EXPECT_LT(errorAtInitial, T(0.01)) << "Error at initial triangle should be near zero"; + } + + // Test 2: Position locator at candidate1 centroid - should slide to it with low error + { + Eigen::Vector3 offset = candidate1Centroid - locator.position.template cast(); + params.v.template segment<3>(locatorParamIdx) = offset; + double errorAtCandidate1 = errorFunction.getError(params, state, meshState); + // Error should be small since it can slide to candidate1 + EXPECT_LT(errorAtCandidate1, T(0.01)) << "Error at candidate1 should be near zero (sliding)"; + } + + // Test 3: Position locator at candidate2 centroid - should slide to it with low error + { + Eigen::Vector3 offset = candidate2Centroid - locator.position.template cast(); + params.v.template segment<3>(locatorParamIdx) = offset; + double errorAtCandidate2 = errorFunction.getError(params, state, meshState); + // Error should be small since it can slide to candidate2 + EXPECT_LT(errorAtCandidate2, T(0.01)) << "Error at candidate2 should be near zero (sliding)"; + } + + // Test 4: Position locator at excluded triangle centroid - should NOT slide there + // Error should be larger since it can only slide to candidates + { + Eigen::Vector3 offset = excludedCentroid - locator.position.template cast(); + params.v.template segment<3>(locatorParamIdx) = offset; + double errorAtExcluded = errorFunction.getError(params, state, meshState); + + // Now create a constraint WITHOUT candidates (fixed to initial triangle) + SkinnedLocatorTriangleConstraintT fixedConstraint; + fixedConstraint.locatorIndex = 0; + fixedConstraint.tgtTriangleIndices = initialTriIndices; + fixedConstraint.tgtTriangleBaryCoords = + Eigen::Vector3(T(1.0 / 3.0), T(1.0 / 3.0), T(1.0 / 3.0)); + fixedConstraint.depth = T(0); + fixedConstraint.weight = T(1); + // No candidate triangles - should behave as fixed constraint + + SkinnedLocatorTriangleErrorFunctionT fixedErrorFunction( + character, VertexConstraintType::Position); + fixedErrorFunction.setConstraints({fixedConstraint}); + + double fixedErrorAtExcluded = fixedErrorFunction.getError(params, state, meshState); + + // The sliding error should be less than or equal to the fixed error + // because sliding can find a closer point among candidates + // (unless excluded triangle happens to be the same as initial, which we avoid) + + // Both errors should be non-zero since the locator is at the excluded triangle + // but the sliding constraint finds the closest candidate while fixed stays at initial + EXPECT_GT(errorAtExcluded, T(0.0)) + << "Error at excluded triangle should be non-zero for sliding constraint"; + EXPECT_GT(fixedErrorAtExcluded, T(0.0)) + << "Error at excluded triangle should be non-zero for fixed constraint"; + } + + // Note: We intentionally don't test gradients/jacobians for sliding constraints + // because the sliding introduces non-differentiable discontinuities when switching + // between triangles. The Jacobian is computed as if the current triangle is fixed, + // which is a common approximation for closest-point constraints, but it won't match + // numerical gradients at switching boundaries. +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, TestSkinningErrorFunction) { + using T = typename TestFixture::Type; + + // this unit tests checks the accuracy of our linear skinning constraint accuracy + // against our simplified approximation that's way faster. + // we expect our gradients to be within 10% of the true gradients of the mesh + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + const auto mesh = character.mesh->cast(); + const auto& skin = *character.skinWeights; + VectorX parameters = VectorX::Zero(transform.numAllModelParameters()); + + VectorX gradient = VectorX::Zero(transform.numAllModelParameters()); + + // create constraints + std::vector> cl; + PositionErrorFunctionT errorFunction(skeleton, character.parameterTransform); + SkeletonStateT bindState(transform.apply(parameters), skeleton); + SkeletonStateT state(transform.apply(parameters), skeleton); + MeshStateT meshState; + TransformationListT bindpose; + for (const auto& js : bindState.jointState) { + bindpose.push_back(js.transform.inverse().toAffine3()); + } + + { + SCOPED_TRACE("Skinning mesh constraint test"); + + for (size_t vi = 0; vi < mesh.vertices.size(); vi++) { + const Eigen::Vector3 target = mesh.vertices[vi]; + + // add vertex to constraint list + cl.clear(); + for (size_t si = 0; si < kMaxSkinJoints; si++) { + if (skin.weight(vi, si) == 0.0) { + continue; + } + const auto parent = skin.index(vi, si); + cl.push_back( + PositionDataT( + (target - bindState.jointState[parent].translation()), + target, + parent, + skin.weight(vi, si))); + } + errorFunction.setConstraints(cl); + + std::vector> v = applySSD(bindpose, skin, mesh.vertices, bindState); + + // check position of skinning + EXPECT_LE((v[vi] - target).norm(), Eps(2e-7f, 2e-7)); + + // check error + gradient.setZero(); + T gradientError = errorFunction.getGradient(parameters, bindState, meshState, gradient); + EXPECT_NEAR(gradientError, 0, Eps(1e-15f, 1e-15)); + EXPECT_LE(gradient.norm(), Eps(1e-7f, 1e-7)); + + for (size_t i = 0; i < 10; i++) { + parameters = uniform>(transform.numAllModelParameters(), -1, 1); + state.set(transform.apply(parameters), skeleton); + v = applySSD(bindpose, skin, mesh.vertices, state); + + cl.clear(); + for (size_t si = 0; si < kMaxSkinJoints; si++) { + if (skin.weight(vi, si) == 0.0) { + continue; + } + const auto parent = skin.index(vi, si); + const Vector3 offset = state.jointState[parent].transform.inverse() * v[vi]; + cl.push_back(PositionDataT(offset, target, parent, skin.weight(vi, si))); + } + errorFunction.setConstraints(cl); + + gradient.setZero(); + gradientError = errorFunction.getGradient(parameters, state, meshState, gradient); + auto numError = (v[vi] - target).squaredNorm(); + EXPECT_NEAR(gradientError, numError, Eps(1e-5f, 1e-5)); + + // calculate numerical gradient + constexpr T kStepSize = 1e-5; + VectorX numGradient = VectorX::Zero(transform.numAllModelParameters()); + for (auto p = 0; p < transform.numAllModelParameters(); p++) { + // perform higher-order finite differences for accuracy + VectorX params = parameters; + params(p) = parameters(p) - kStepSize; + state.set(transform.apply(params), skeleton); + v = applySSD(bindpose, skin, mesh.vertices, state); + const T h_1 = (v[vi] - target).squaredNorm(); + + params(p) = parameters(p) + kStepSize; + state.set(transform.apply(params), skeleton); + v = applySSD(bindpose, skin, mesh.vertices, state); + const T h1 = (v[vi] - target).squaredNorm(); + + numGradient(p) = (h1 - h_1) / (2.0 * kStepSize); + } + + // check the gradients are similar + { + SCOPED_TRACE("Checking Numerical Gradient"); + if ((numGradient + gradient).norm() != 0.0) { + EXPECT_LE( + (numGradient - gradient).norm() / (numGradient + gradient).norm(), + Eps(1e-1f, 1e-1)); + } + } + } + } + } +} diff --git a/momentum/test/character_solver/state_error_function_test.cpp b/momentum/test/character_solver/state_error_function_test.cpp new file mode 100644 index 0000000000..63dea364fa --- /dev/null +++ b/momentum/test/character_solver/state_error_function_test.cpp @@ -0,0 +1,167 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character_solver/model_parameters_error_function.h" +#include "momentum/character_solver/pose_prior_error_function.h" +#include "momentum/character_solver/state_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, StateError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + StateErrorFunctionT errorFunction(skeleton, character.parameterTransform); + { + SCOPED_TRACE("State Test"); + SkeletonStateT reference(transform.bindPose(), skeleton); + errorFunction.setTargetState(reference); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(2e-5f, 1e-3)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 5e-6)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, StateErrorLogMap_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + StateErrorFunctionT errorFunction( + skeleton, character.parameterTransform, RotationErrorType::QuaternionLogMap); + { + SCOPED_TRACE("State Test"); + SkeletonStateT reference(transform.bindPose(), skeleton); + errorFunction.setTargetState(reference); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(2e-5f, 1e-3)); + EXPECT_EQ(errorFunction.getJacobianSize(), 6 * character.skeleton.joints.size()); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(1e-2f, 5e-6)); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, ModelParametersError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + ModelParametersErrorFunctionT errorFunction(skeleton, character.parameterTransform); + { + SCOPED_TRACE("Motion Test"); + SkeletonStateT reference(transform.bindPose(), skeleton); + VectorX weights = VectorX::Ones(transform.numAllModelParameters()); + weights(0) = 4.0; + weights(1) = 5.0; + weights(2) = 0.0; + errorFunction.setTargetParameters( + ModelParametersT::Zero(transform.numAllModelParameters()), weights); + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-7f, 1e-15)); + for (size_t i = 0; i < 10; i++) { + ModelParametersT parameters = + uniform>(transform.numAllModelParameters(), -1, 1) * 0.25; + TEST_GRADIENT_AND_JACOBIAN(T, &errorFunction, parameters, character, Eps(5e-3f, 1e-10)); + } + } +} + +// Show an example of regularizing the blend weights: +TYPED_TEST(Momentum_ErrorFunctionsTest, ModelParametersError_RegularizeBlendWeights) { + using T = typename TestFixture::Type; + + const Character character = withTestBlendShapes(createTestCharacter()); + const ModelParametersT modelParams = + 0.25 * uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); + ModelParametersErrorFunctionT errorFunction( + character, character.parameterTransform.getBlendShapeParameters()); + EXPECT_GT( + errorFunction.getError( + modelParams, + SkeletonStateT( + character.parameterTransform.cast().apply(modelParams), character.skeleton), + MeshStateT()), + 0); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, modelParams, character, Eps(1e-3f, 1e-10), Eps(1e-7f, 1e-7), true); + + ModelParametersErrorFunctionT errorFunction2( + character, character.parameterTransform.getScalingParameters()); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction2, modelParams, character, Eps(1e-3f, 5e-12), Eps(1e-6f, 1e-7), true); +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PosePriorError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // create skeleton and reference values + const Character character = createTestCharacter(); + const ParameterTransformT transform = character.parameterTransform.cast(); + + // create constraints + PosePriorErrorFunctionT errorFunction( + character.skeleton, character.parameterTransform, createDefaultPosePrior()); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + ModelParametersT::Zero(transform.numAllModelParameters()), + character, + Eps(1e-1f, 1e-10), + Eps(1e-5f, 5e-6)); + + for (size_t i = 0; i < 3; i++) { + ModelParametersT parameters = uniform>(transform.numAllModelParameters(), -1, 1); + TEST_GRADIENT_AND_JACOBIAN( + T, &errorFunction, parameters, character, Eps(5e-1f, 1e-9), Eps(5e-5f, 5e-6)); + } +} diff --git a/momentum/test/character_solver/vertex_error_function_test.cpp b/momentum/test/character_solver/vertex_error_function_test.cpp new file mode 100644 index 0000000000..ae50597f74 --- /dev/null +++ b/momentum/test/character_solver/vertex_error_function_test.cpp @@ -0,0 +1,710 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "momentum/character/character.h" +#include "momentum/character/linear_skinning.h" +#include "momentum/character/mesh_state.h" +#include "momentum/character/sdf_collision_geometry.h" +#include "momentum/character/skeleton.h" +#include "momentum/character/skeleton_state.h" +#include "momentum/character/skin_weights.h" +#include "momentum/character_solver/point_triangle_vertex_error_function.h" +#include "momentum/character_solver/vertex_error_function.h" +#include "momentum/character_solver/vertex_projection_error_function.h" +#include "momentum/character_solver/vertex_sdf_error_function.h" +#include "momentum/character_solver/vertex_vertex_distance_error_function.h" +#include "momentum/math/random.h" +#include "momentum/test/character/character_helpers.h" +#include "momentum/test/character_solver/error_function_helpers.h" + +#include + +using namespace momentum; + +using Types = testing::Types; + +TYPED_TEST_SUITE(Momentum_ErrorFunctionsTest, Types); + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexVertexDistanceError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); + + // create skeleton and reference values + const size_t nConstraints = 10; + + // Test WITHOUT blend shapes: + { + SCOPED_TRACE("Without blend shapes"); + + const Character character_orig = createTestCharacter(); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ParameterTransformT transform = character_orig.parameterTransform.cast(); + + VertexVertexDistanceErrorFunctionT errorFunction(character_orig); + + // Add some vertex-vertex distance constraints + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int vertexIndex1 = + uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); + int vertexIndex2 = + uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); + + // Ensure we don't constrain the same vertex to itself + while (vertexIndex2 == vertexIndex1) { + vertexIndex2 = uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); + } + + const T weight = uniform(0.1, 2.0); + const T targetDistance = uniform(0.1, 1.0); + + errorFunction.addConstraint(vertexIndex1, vertexIndex2, weight, targetDistance); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_orig, + Eps(5e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + } + + // Test WITH blend shapes: + { + SCOPED_TRACE("With blend shapes"); + + const Character character_blend = withTestBlendShapes(createTestCharacter()); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ParameterTransformT transform = character_blend.parameterTransform.cast(); + + VertexVertexDistanceErrorFunctionT errorFunction(character_blend); + + // Add some vertex-vertex distance constraints + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int vertexIndex1 = + uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); + int vertexIndex2 = + uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); + + // Ensure we don't constrain the same vertex to itself + while (vertexIndex2 == vertexIndex1) { + vertexIndex2 = uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); + } + + const T weight = uniform(0.1, 2.0); + const T targetDistance = uniform(0.1, 1.0); + + errorFunction.addConstraint(vertexIndex1, vertexIndex2, weight, targetDistance); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_blend, + Eps(1e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + } + + // Test that error is zero when vertices are at target distance + { + SCOPED_TRACE("Zero error test"); + + const Character character = createTestCharacter(); + const ModelParametersT modelParams = + ModelParametersT::Zero(character.parameterTransform.numAllModelParameters()); + const SkeletonStateT skelState( + character.parameterTransform.cast().apply(modelParams), character.skeleton); + + VertexVertexDistanceErrorFunctionT errorFunction(character); + + // Calculate actual distance between two vertices + momentum::TransformationListT ibp; + for (const auto& js : character.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character.mesh->cast(); + const auto& skin = *character.skinWeights; + momentum::MeshT posedMesh = character.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, posedMesh); + + const int vertexIndex1 = 0; + const int vertexIndex2 = 1; + const T actualDistance = + (posedMesh.vertices[vertexIndex1] - posedMesh.vertices[vertexIndex2]).norm(); + + // Add constraint with the actual distance as target + errorFunction.addConstraint(vertexIndex1, vertexIndex2, T(1.0), actualDistance); + + const double error = errorFunction.getError( + modelParams, skelState, MeshStateT(modelParams, skelState, character)); + EXPECT_NEAR(error, 0.0, Eps(1e-10f, 1e-15)); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexErrorFunctionSerial) { + using T = typename TestFixture::Type; + SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); + + // create skeleton and reference values + + const size_t nConstraints = 10; + + // Test WITHOUT blend shapes: + { + SCOPED_TRACE("Without blend shapes"); + + const Character character_orig = createTestCharacter(); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ModelParametersT modelParamsTarget = refParams + + 0.05 * + uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const Skeleton& skeleton = character_orig.skeleton; + const ParameterTransformT transform = character_orig.parameterTransform.cast(); + const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); + momentum::TransformationListT ibp; + for (const auto& js : character_orig.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character_orig.mesh->cast(); + const auto& skin = *character_orig.skinWeights; + momentum::MeshT targetMesh = character_orig.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, targetMesh); + + for (VertexConstraintType type : + {VertexConstraintType::Position, + VertexConstraintType::Plane, + VertexConstraintType::Normal, + VertexConstraintType::SymmetricNormal}) { + SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); + + const T errorTol = [&]() { + switch (type) { + case VertexConstraintType::Position: + case VertexConstraintType::Plane: + return Eps(5e-2f, 1e-5); + + // TODO Normal constraints have a much higher epsilon than I'd prefer to see; + // it would be good to dig into this. + case VertexConstraintType::Normal: + case VertexConstraintType::SymmetricNormal: + return Eps(5e-2f, 5e-2); + + default: + // Shouldn't reach here + return T(0); + } + }(); + + VertexErrorFunctionT errorFunction(character_orig, type, 0); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int index = + uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); + errorFunction.addConstraint( + index, uniform(0, 1e-2), targetMesh.vertices[index], targetMesh.normals[index]); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_orig, + errorTol, + Eps(1e-6f, 1e-14), + true, + false); + } + } + + // Test WITH blend shapes: + { + SCOPED_TRACE("With blend shapes"); + + const Character character_blend = withTestBlendShapes(createTestCharacter()); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ModelParametersT modelParamsTarget = refParams + + 0.05 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + const Skeleton& skeleton = character_blend.skeleton; + const ParameterTransformT transform = character_blend.parameterTransform.cast(); + const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); + momentum::TransformationListT ibp; + for (const auto& js : character_blend.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character_blend.mesh->cast(); + const auto& skin = *character_blend.skinWeights; + momentum::MeshT targetMesh = character_blend.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, targetMesh); + + // It's trickier to test Normal and SymmetricNormal constraints in the blend shape case because + // the mesh normals are recomputed after blend shapes are applied (this is the only sensible + // thing to do since the blend shapes can drastically change the shape) and thus the normals + // depend on the blend shapes in a very complicated way that we aren't currently trying to + // model. + for (VertexConstraintType type : + {VertexConstraintType::Position, VertexConstraintType::Plane}) { + SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); + + VertexErrorFunctionT errorFunction(character_blend, type); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int index = + uniform(0, static_cast(character_blend.mesh->vertices.size() - 1)); + errorFunction.addConstraint( + index, uniform(0, 1e-2), targetMesh.vertices[index], targetMesh.normals[index]); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_blend, + Eps(1e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexErrorFunctionParallel) { + using T = typename TestFixture::Type; + SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); + + // create skeleton and reference values + + const size_t nConstraints = 1000; + + const Character character_orig = createTestCharacter(); + const Eigen::VectorX refParams = + 0.25 * uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ModelParametersT modelParamsTarget = refParams + + 0.05 * uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const Skeleton& skeleton = character_orig.skeleton; + const ParameterTransformT transform = character_orig.parameterTransform.cast(); + const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); + momentum::TransformationListT ibp; + for (const auto& js : character_orig.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character_orig.mesh->cast(); + const auto& skin = *character_orig.skinWeights; + momentum::MeshT targetMesh = character_orig.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, targetMesh); + + for (VertexConstraintType type : + {VertexConstraintType::Position, + VertexConstraintType::Plane, + VertexConstraintType::Normal, + VertexConstraintType::SymmetricNormal}) { + SCOPED_TRACE(fmt::format("Constraint type: {}", toString(type))); + + const T errorTol = [&]() { + switch (type) { + case VertexConstraintType::Position: + case VertexConstraintType::Plane: + return Eps(5e-2f, 1e-5); + + // TODO Normal constraints have a much higher epsilon than I'd prefer to see; + // it would be good to dig into this. + case VertexConstraintType::Normal: + case VertexConstraintType::SymmetricNormal: + return Eps(5e-2f, 5e-2); + + default: + // Shouldn't reach here + return T(0); + } + }(); + + VertexErrorFunctionT errorFunction(character_orig, type, 100000); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int index = uniform(0, static_cast(character_orig.mesh->vertices.size() - 1)); + errorFunction.addConstraint( + index, uniform(0, 1e-4), targetMesh.vertices[index], targetMesh.normals[index]); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_orig, + errorTol, + Eps(1e-6f, 1e-15), + true, + false); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexProjectionErrorFunction) { + using T = typename TestFixture::Type; + SCOPED_TRACE(fmt::format("ScalarType: {}", typeid(T).name())); + + // create skeleton and reference values + + const size_t nConstraints = 10; + + // Test WITHOUT blend shapes: + { + SCOPED_TRACE("Without blend shapes"); + + const Character character_orig = createTestCharacter(); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ModelParametersT modelParamsTarget = refParams + + 0.05 * + uniform>(character_orig.parameterTransform.numAllModelParameters(), -1, 1); + const Skeleton& skeleton = character_orig.skeleton; + const ParameterTransformT transform = character_orig.parameterTransform.cast(); + const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); + momentum::TransformationListT ibp; + for (const auto& js : character_orig.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character_orig.mesh->cast(); + const auto& skin = *character_orig.skinWeights; + momentum::MeshT targetMesh = character_orig.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, targetMesh); + + Eigen::Matrix projection = Eigen::Matrix::Identity(); + projection(0, 0) = 10.0; + projection(1, 1) = 10.0; + projection(2, 3) = 10.0; + + const T errorTol = Eps(5e-2f, 1e-5); + + VertexProjectionErrorFunctionT errorFunction(character_orig, 0); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int index = uniform(0, character_orig.mesh->vertices.size() - 1); + const Eigen::Vector3 target = projection * targetMesh.vertices[index].homogeneous(); + const Eigen::Vector2 target2d = target.hnormalized() + uniform>(-1, 1) * 0.1; + errorFunction.addConstraint(index, uniform(0, 1e-2), target2d, projection); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_orig, + errorTol, + Eps(1e-6f, 1e-14), + true, + false); + } + + // Test WITH blend shapes: + { + SCOPED_TRACE("With blend shapes"); + + const Character character_blend = withTestBlendShapes(createTestCharacter()); + const Eigen::VectorX refParams = 0.25 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + const ModelParametersT modelParams = refParams; + const ModelParametersT modelParamsTarget = refParams + + 0.05 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + const Skeleton& skeleton = character_blend.skeleton; + const ParameterTransformT transform = character_blend.parameterTransform.cast(); + const momentum::SkeletonStateT skelState(transform.apply(modelParamsTarget), skeleton); + momentum::TransformationListT ibp; + for (const auto& js : character_blend.inverseBindPose) { + ibp.push_back(js.cast()); + } + const auto mesh = character_blend.mesh->cast(); + const auto& skin = *character_blend.skinWeights; + momentum::MeshT targetMesh = character_blend.mesh->cast(); + applySSD(ibp, skin, mesh, skelState, targetMesh); + + Eigen::Matrix projection = Eigen::Matrix::Identity(); + projection(2, 3) = 10; + + // It's trickier to test Normal and SymmetricNormal constraints in the blend shape case because + // the mesh normals are recomputed after blend shapes are applied (this is the only sensible + // thing to do since the blend shapes can drastically change the shape) and thus the normals + // depend on the blend shapes in a very complicated way that we aren't currently trying to + // model. + VertexProjectionErrorFunctionT errorFunction(character_blend); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + const int index = uniform(0, character_blend.mesh->vertices.size() - 1); + const Eigen::Vector3 target = projection * targetMesh.vertices[index].homogeneous(); + const Eigen::Vector2 target2d = target.hnormalized(); + errorFunction.addConstraint(index, uniform(0, 1e-2), target2d, projection); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_blend, + Eps(1e-2f, 1e-5), + Eps(1e-6f, 1e-14), + true, + false); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, PointTriangleVertexErrorFunction) { + using T = typename TestFixture::Type; + + // The Normal and SymmetricNormal tests are currently failing, for very confusing + // reasons. We get large nonzero values in the Jacobian for the _rigid_ parameters + // when computing them _numerically_, which suggests something funny is going on + // that makes them depend on e.g. a rigid translation of the entire model -- but + // only for Normal/SymmetricNormal constrains. This seems especially weird to me + // for global translations, because the skinning code doesn't even use the translation + // part of the matrix when skinning normals. + // + // For now I'm only using Plane constraints anyway, because letting the Triangle + // in the PointTriangle constraint determine what the normal is seems like the right + // move. At some point we should revisit, either to disable support for Normal constraints + // altogether or figure out where this very weird issue comes from. + + for (const auto& constraintType : { + VertexConstraintType::Position, + VertexConstraintType::Plane, + // VertexConstraintType::Normal, + // VertexConstraintType::SymmetricNormal + }) { + Character character = createTestCharacter(); + character = withTestBlendShapes(character); + + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + SCOPED_TRACE("constraintType: " + std::string(toString(constraintType))); + + PointTriangleVertexErrorFunctionT errorFunction(character, constraintType); + + // Create a simple triangle and a point above it + { + Eigen::Vector3i triangleIndices(0, 1, 2); + Eigen::Vector3 triangleBaryCoords(0.3, 0.3, 0.4); + T depth = 0.1; + T weight = 1.0; + + // Add a constraint + errorFunction.addConstraint(3, triangleIndices, triangleBaryCoords, depth, weight); + } + + { + Eigen::Vector3i triangleIndices(10, 12, 14); + Eigen::Vector3 triangleBaryCoords(0.2, 0.4, 0.4); + T depth = 0.2; + T weight = 0.5; + + // Add a constraint + errorFunction.addConstraint(3, triangleIndices, triangleBaryCoords, depth, weight); + } + + // Set up model parameters and skeleton state + // ModelParametersT modelParams = + // ModelParametersT::Zero(transform.numAllModelParameters()); + const ModelParametersT modelParams = + 0.25 * uniform>(character.parameterTransform.numAllModelParameters(), -1, 1); + + SkeletonStateT state(transform.apply(modelParams), skeleton); + + // Test error calculation + double error = + errorFunction.getError(modelParams, state, MeshStateT(modelParams, state, character)); + EXPECT_GT(error, 0); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character, + Eps(5e-2f, 5e-4), + Eps(1e-6f, 1e-13), + true, + true); + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexPositionErrorFunctionFaceParameters) { + using T = typename TestFixture::Type; + + const size_t nConstraints = 10; + + // Face expression blend shapes only + { + const Character character_blend = withTestFaceExpressionBlendShapes(createTestCharacter()); + const ModelParametersT modelParams = 0.25 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + + // TODO: Add Plane, Normal and SymmetricNormal? + for (VertexConstraintType type : {VertexConstraintType::Position}) { + VertexErrorFunctionT errorFunction(character_blend, type); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + errorFunction.addConstraint( + uniform(0, character_blend.mesh->vertices.size() - 1), + uniform(0, 1), + uniform>(0, 1), + uniform>(0.1, 1).normalized()); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_blend, + Eps(5e-2f, 1e-5), + Eps(1e-6f, 5e-16), + true, + false); + } + } + + // Face expression blend shapes plus shape blend shapes + { + const Character character_blend = + withTestBlendShapes(withTestFaceExpressionBlendShapes(createTestCharacter())); + const ModelParametersT modelParams = 0.25 * + uniform>(character_blend.parameterTransform.numAllModelParameters(), -1, 1); + + // TODO: Add Plane, Normal and SymmetricNormal? + for (VertexConstraintType type : {VertexConstraintType::Position}) { + VertexErrorFunctionT errorFunction(character_blend, type); + for (size_t iCons = 0; iCons < nConstraints; ++iCons) { + errorFunction.addConstraint( + uniform(0, character_blend.mesh->vertices.size() - 1), + uniform(0, 1), + uniform>(0, 1), + uniform>(0.1, 1).normalized()); + } + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + modelParams, + character_blend, + Eps(1e-2f, 1e-5), + Eps(1e-6f, 5e-16), + true, + false); + } + } +} + +TYPED_TEST(Momentum_ErrorFunctionsTest, VertexSDFError_GradientsAndJacobians) { + using T = typename TestFixture::Type; + + // Create test character + const Character character = createTestCharacter(5); + const Skeleton& skeleton = character.skeleton; + const ParameterTransformT transform = character.parameterTransform.cast(); + + // Create a sphere SDF attached to joint 2 + auto sdfLocal = std::make_shared>( + axel::SignedDistanceField::createSphere(3.0, Eigen::Vector3(10, 10, 10))); + + SDFCollider sdfCollider{ + TransformT(Eigen::Vector3f(-0.224f, 0.134f, 0.151f)), + 2, // parent joint index + sdfLocal}; + + VertexSDFErrorFunctionT errorFunction(character, sdfCollider); + + // Add constraints with various target distances + const size_t numTestVertices = std::min(size_t(5), character.mesh->vertices.size()); + for (size_t i = 0; i < numTestVertices; ++i) { + VertexSDFConstraintT constraint; + constraint.vertexIndex = static_cast(i); + constraint.weight = uniform(0.1, 2.0); + + // Vary target distances: surface (0), outside (1), inside (-0.5) + if (i % 3 == 0) { + constraint.targetDistance = T(0); + } else if (i % 3 == 1) { + constraint.targetDistance = T(1); + } else { + constraint.targetDistance = T(-0.5); + } + + errorFunction.addConstraint(constraint); + } + + EXPECT_EQ(errorFunction.numConstraints(), numTestVertices); + + { + SCOPED_TRACE("Vertex SDF Error Test - Zero Parameters"); + + const ModelParametersT zeroParams = + ModelParametersT::Zero(transform.numAllModelParameters()); + const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); + const MeshStateT zeroMeshState(zeroParams, zeroState, character); + + const double zeroError = errorFunction.getError(zeroParams, zeroState, zeroMeshState); + EXPECT_GE(zeroError, 0.0); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + zeroParams, + character, + Eps(0.1f, 0.04), + Eps(1e-6f, 1e-14), + true, + true); + } + + { + SCOPED_TRACE("Vertex SDF Error Test - Random Parameters"); + + for (size_t i = 0; i < 5; i++) { + ModelParametersT parameters = + 0.1 * uniform>(transform.numAllModelParameters(), -1, 1); + + const SkeletonStateT state(transform.apply(parameters), skeleton); + const MeshStateT meshState(parameters, state, character); + const double error = errorFunction.getError(parameters, state, meshState); + EXPECT_GE(error, 0.0); + + TEST_GRADIENT_AND_JACOBIAN( + T, + &errorFunction, + parameters, + character, + Eps(0.3f, 0.04), + Eps(1e-6f, 1e-14), + true, + true); + } + } + + // Test that clearing constraints results in zero error + { + SCOPED_TRACE("Clear Constraints Test"); + + errorFunction.clearConstraints(); + EXPECT_EQ(errorFunction.numConstraints(), 0); + + const ModelParametersT zeroParams = + ModelParametersT::Zero(transform.numAllModelParameters()); + const SkeletonStateT zeroState(transform.apply(zeroParams), skeleton); + const MeshStateT zeroMeshState(zeroParams, zeroState, character); + + const double error = errorFunction.getError(zeroParams, zeroState, zeroMeshState); + EXPECT_EQ(error, 0.0); + } +} diff --git a/momentum/test/math/mesh_test.cpp b/momentum/test/math/mesh_test.cpp index 34c3824a63..d0e64fe540 100644 --- a/momentum/test/math/mesh_test.cpp +++ b/momentum/test/math/mesh_test.cpp @@ -38,6 +38,9 @@ TYPED_TEST(MeshTest, DefaultConstructor) { EXPECT_TRUE(mesh.texcoords.empty()); EXPECT_TRUE(mesh.texcoord_faces.empty()); EXPECT_TRUE(mesh.texcoord_lines.empty()); + EXPECT_TRUE(mesh.polyFaces.empty()); + EXPECT_TRUE(mesh.polyFaceSizes.empty()); + EXPECT_TRUE(mesh.polyTexcoordFaces.empty()); } // Test updateNormals with a tetrahedron @@ -166,6 +169,11 @@ TYPED_TEST(MeshTest, Cast) { mesh.confidence = {0.5, 0.6, 0.7}; + // Add polygon data: a quad (v0,v1,v2,v3) + a triangle (v0,v1,v2) + mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2}; + mesh.polyFaceSizes = {4, 3}; + mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16}; + // Cast to the same type (should be a copy) auto sameMesh = mesh.template cast(); @@ -179,6 +187,9 @@ TYPED_TEST(MeshTest, Cast) { EXPECT_TRUE(sameMesh.normals[i].isApprox(mesh.normals[i])); EXPECT_NEAR(sameMesh.confidence[i], mesh.confidence[i], Eps(1e-5f, 1e-13)); } + EXPECT_EQ(sameMesh.polyFaces, mesh.polyFaces); + EXPECT_EQ(sameMesh.polyFaceSizes, mesh.polyFaceSizes); + EXPECT_EQ(sameMesh.polyTexcoordFaces, mesh.polyTexcoordFaces); // Cast to the other type using OtherT = typename std::conditional::value, double, float>::type; @@ -195,6 +206,9 @@ TYPED_TEST(MeshTest, Cast) { // Use a more relaxed tolerance for float-to-double or double-to-float conversions EXPECT_NEAR(static_cast(otherMesh.confidence[i]), mesh.confidence[i], Eps(1e-4f, 1e-6)); } + EXPECT_EQ(otherMesh.polyFaces, mesh.polyFaces); + EXPECT_EQ(otherMesh.polyFaceSizes, mesh.polyFaceSizes); + EXPECT_EQ(otherMesh.polyTexcoordFaces, mesh.polyTexcoordFaces); } // Test reset method @@ -225,6 +239,10 @@ TYPED_TEST(MeshTest, Reset) { mesh.texcoord_lines = {{0, 1}, {1, 2}, {2, 0}}; + mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2}; + mesh.polyFaceSizes = {4, 3}; + mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16}; + // Reset the mesh mesh.reset(); @@ -238,6 +256,9 @@ TYPED_TEST(MeshTest, Reset) { EXPECT_TRUE(mesh.texcoords.empty()); EXPECT_TRUE(mesh.texcoord_faces.empty()); EXPECT_TRUE(mesh.texcoord_lines.empty()); + EXPECT_TRUE(mesh.polyFaces.empty()); + EXPECT_TRUE(mesh.polyFaceSizes.empty()); + EXPECT_TRUE(mesh.polyTexcoordFaces.empty()); } // Test with a complex mesh @@ -351,6 +372,9 @@ TYPED_TEST(MeshTest, EmptyMesh) { EXPECT_TRUE(castedMesh.texcoords.empty()); EXPECT_TRUE(castedMesh.texcoord_faces.empty()); EXPECT_TRUE(castedMesh.texcoord_lines.empty()); + EXPECT_TRUE(castedMesh.polyFaces.empty()); + EXPECT_TRUE(castedMesh.polyFaceSizes.empty()); + EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty()); // Reset an empty mesh EXPECT_NO_THROW(mesh.reset()); @@ -462,3 +486,66 @@ TYPED_TEST(MeshTest, Lines) { mesh.reset(); EXPECT_TRUE(mesh.lines.empty()); } + +// Test polygon face representation +TYPED_TEST(MeshTest, PolygonFaces) { + using T = TypeParam; + using MeshType = typename TestFixture::MeshType; + using Vector3 = Eigen::Vector3; + + MeshType mesh; + + // Create a mesh with 5 vertices + mesh.vertices = { + Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3(0, 1, 0), Vector3(0.5, 0.5, 1)}; + + // Polygon representation: a quad (4 verts) + a triangle (3 verts) + mesh.polyFaces = {0, 1, 2, 3, 0, 1, 4}; + mesh.polyFaceSizes = {4, 3}; + mesh.polyTexcoordFaces = {0, 1, 2, 3, 4, 5, 6}; + + // Verify sizes sum matches polyFaces length + uint32_t totalSize = 0; + for (auto s : mesh.polyFaceSizes) { + totalSize += s; + } + EXPECT_EQ(totalSize, mesh.polyFaces.size()); + EXPECT_EQ(totalSize, mesh.polyTexcoordFaces.size()); + + // Cast and verify preservation + using OtherT = typename std::conditional::value, double, float>::type; + auto otherMesh = mesh.template cast(); + EXPECT_EQ(otherMesh.polyFaces, mesh.polyFaces); + EXPECT_EQ(otherMesh.polyFaceSizes, mesh.polyFaceSizes); + EXPECT_EQ(otherMesh.polyTexcoordFaces, mesh.polyTexcoordFaces); + + // Reset and verify cleared + mesh.reset(); + EXPECT_TRUE(mesh.polyFaces.empty()); + EXPECT_TRUE(mesh.polyFaceSizes.empty()); + EXPECT_TRUE(mesh.polyTexcoordFaces.empty()); +} + +// Test polygon faces with empty texcoord faces +TYPED_TEST(MeshTest, PolygonFacesNoTexcoords) { + using T = TypeParam; + using MeshType = typename TestFixture::MeshType; + using Vector3 = Eigen::Vector3; + + MeshType mesh; + + mesh.vertices = {Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 1, 0)}; + mesh.polyFaces = {0, 1, 2}; + mesh.polyFaceSizes = {3}; + // polyTexcoordFaces intentionally left empty + + EXPECT_EQ(mesh.polyFaces.size(), 3u); + EXPECT_EQ(mesh.polyFaceSizes.size(), 1u); + EXPECT_TRUE(mesh.polyTexcoordFaces.empty()); + + // Cast preserves the empty texcoord faces + auto castedMesh = mesh.template cast(); + EXPECT_EQ(castedMesh.polyFaces, mesh.polyFaces); + EXPECT_EQ(castedMesh.polyFaceSizes, mesh.polyFaceSizes); + EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty()); +} diff --git a/pymomentum/geometry/mesh_pybind.cpp b/pymomentum/geometry/mesh_pybind.cpp index 6e57114331..2e9ca481b8 100644 --- a/pymomentum/geometry/mesh_pybind.cpp +++ b/pymomentum/geometry/mesh_pybind.cpp @@ -29,6 +29,52 @@ namespace pymomentum { namespace { +void validateAndSetPolyData( + mm::Mesh& mesh, + const std::vector& poly_faces, + const std::vector& poly_face_sizes, + const std::vector& poly_texcoord_faces, + int nVerts, + int nTextureCoords) { + MT_THROW_IF( + poly_faces.empty() != poly_face_sizes.empty(), + "poly_faces and poly_face_sizes must both be empty or both be non-empty"); + if (!poly_face_sizes.empty()) { + uint32_t totalSize = 0; + for (const auto s : poly_face_sizes) { + MT_THROW_IF(s < 3, "poly_face_sizes entries must be >= 3, got {}", s); + totalSize += s; + } + MT_THROW_IF( + totalSize != poly_faces.size(), + "poly_face_sizes sum ({}) must equal poly_faces length ({})", + totalSize, + poly_faces.size()); + for (const auto idx : poly_faces) { + MT_THROW_IF( + idx >= static_cast(nVerts), + "poly_faces index ({}) exceeded vertex count ({})", + idx, + nVerts); + } + MT_THROW_IF( + !poly_texcoord_faces.empty() && poly_texcoord_faces.size() != poly_faces.size(), + "poly_texcoord_faces must be empty or same length as poly_faces ({} vs {})", + poly_texcoord_faces.size(), + poly_faces.size()); + for (const auto idx : poly_texcoord_faces) { + MT_THROW_IF( + idx >= static_cast(nTextureCoords), + "poly_texcoord_faces index ({}) exceeded texcoord count ({})", + idx, + nTextureCoords); + } + } + mesh.polyFaces = poly_faces; + mesh.polyFaceSizes = poly_face_sizes; + mesh.polyTexcoordFaces = poly_texcoord_faces; +} + mm::Mesh createMesh( const py::array_t& vertices, const py::array_t& faces, @@ -38,7 +84,10 @@ mm::Mesh createMesh( const std::vector& confidence, std::optional> texcoords, std::optional> texcoord_faces, - const std::vector>& texcoord_lines) { + const std::vector>& texcoord_lines, + const std::vector& poly_faces, + const std::vector& poly_face_sizes, + const std::vector& poly_texcoord_faces) { mm::Mesh mesh; MT_THROW_IF(vertices.ndim() != 2, "vertices must be a 2D array"); MT_THROW_IF(vertices.shape(1) != 3, "vertices must have size n x 3"); @@ -110,6 +159,14 @@ mm::Mesh createMesh( mesh.texcoord_lines = texcoord_lines; + validateAndSetPolyData( + mesh, + poly_faces, + poly_face_sizes, + poly_texcoord_faces, + static_cast(nVerts), + nTextureCoords); + return mesh; } @@ -136,6 +193,9 @@ void registerMeshBindings(py::class_& meshClass) { :param texcoords: Optional n x 2 array of texture coordinates. :param texcoord_faces: Optional n x 3 array of triangles in the texture map. Each triangle corresponds to a triangle on the mesh, but indices should refer to the texcoord array. :param texcoord_lines: Optional list of lines, where each line is a list of texture coordinate indices. +:param poly_faces: Optional list of packed polygon face vertex indices (all polygons concatenated). The triangulated ``faces`` parameter is always required; polygon data is optional and preserves original topology. +:param poly_face_sizes: Optional list of vertex counts per polygon face. +:param poly_texcoord_faces: Optional list of packed polygon face texture coordinate indices (same layout as poly_faces). )", py::arg("vertices"), py::arg("faces"), @@ -146,7 +206,10 @@ void registerMeshBindings(py::class_& meshClass) { py::arg("confidence") = std::vector{}, py::arg("texcoords") = std::optional>{}, py::arg("texcoord_faces") = std::optional>{}, - py::arg("texcoord_lines") = std::vector>{}) + py::arg("texcoord_lines") = std::vector>{}, + py::arg("poly_faces") = std::vector{}, + py::arg("poly_face_sizes") = std::vector{}, + py::arg("poly_texcoord_faces") = std::vector{}) .def_property_readonly( "n_vertices", [](const mm::Mesh& mesh) { return mesh.vertices.size(); }, @@ -188,6 +251,24 @@ void registerMeshBindings(py::class_& meshClass) { "texcoord_lines", &mm::Mesh::texcoord_lines, "Texture coordinate indices for each line. ") + .def_readonly( + "poly_faces", + &mm::Mesh::polyFaces, + "Packed polygon face vertex indices (all polygons concatenated back-to-back). " + "This is optional — the triangulated :attr:`faces` field is always required.") + .def_readonly( + "poly_face_sizes", + &mm::Mesh::polyFaceSizes, + "Number of vertices in each polygon face. May be empty if polygon data is not available.") + .def_readonly( + "poly_texcoord_faces", + &mm::Mesh::polyTexcoordFaces, + "Packed polygon face texture coordinate indices (same layout as :attr:`poly_faces`). " + "May be empty if texture coordinates are not available.") + .def_property_readonly( + "n_poly_faces", + [](const mm::Mesh& mesh) { return mesh.polyFaceSizes.size(); }, + ":return: The number of polygon faces in the mesh.") .def( "self_intersections", [](const mm::Mesh& mesh) { @@ -204,12 +285,13 @@ void registerMeshBindings(py::class_& meshClass) { }) .def("__repr__", [](const mm::Mesh& m) { return fmt::format( - "Mesh(vertices={}, faces={}, has_normals={}, has_colors={}, has_texcoords={})", + "Mesh(vertices={}, faces={}, has_normals={}, has_colors={}, has_texcoords={}, poly_faces={})", m.vertices.size(), m.faces.size(), !m.normals.empty() ? "True" : "False", !m.colors.empty() ? "True" : "False", - !m.texcoords.empty() ? "True" : "False"); + !m.texcoords.empty() ? "True" : "False", + m.polyFaceSizes.size()); }); }