/* * All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or * its licensors. * * For complete copyright and license terms please see the LICENSE at the root of this * distribution (the "License"). All use of this software is governed by the License, * or, if provided, by the license below or the license accompanying this file. Do not * remove or modify any license notices. This file is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * */ #include <PhysXCharacters_precompiled.h> #include "TestEnvironment.h" #include <API/CharacterController.h> #include <API/Utils.h> #include <Components/CharacterControllerComponent.h> #include <PhysXCharacters/SystemBus.h> #include <System/SystemComponent.h> #include <AzCore/UnitTest/Helpers.h> #include <AzFramework/Components/TransformComponent.h> #include <PhysX/ComponentTypeIds.h> #include <PhysX/SystemComponentBus.h> namespace PhysXCharacters { class ControllerTestBasis { public: ControllerTestBasis(const Physics::ShapeType shapeType = Physics::ShapeType::Capsule, const AZ::Transform& floorTransform = DefaultFloorTransform) { SetUp(shapeType, floorTransform); } void SetUp(const Physics::ShapeType shapeType = Physics::ShapeType::Capsule, const AZ::Transform& floorTransform = DefaultFloorTransform) { Physics::DefaultWorldBus::BroadcastResult(m_world, &Physics::DefaultWorldRequests::GetDefaultWorld); m_floor = Physics::AddStaticFloorToWorld(m_world.get(), floorTransform); CharacterControllerConfiguration characterConfig; characterConfig.m_maximumSlopeAngle = 25.0f; characterConfig.m_stepHeight = 0.2f; if (shapeType == Physics::ShapeType::Box) { Physics::BoxShapeConfiguration shapeConfig(AZ::Vector3(0.5f, 0.5f, 1.0f)); Physics::CharacterSystemRequestBus::BroadcastResult(m_controller, &Physics::CharacterSystemRequests::CreateCharacter, characterConfig, shapeConfig, *m_world); } else { Physics::CapsuleShapeConfiguration shapeConfig; Physics::CharacterSystemRequestBus::BroadcastResult(m_controller, &Physics::CharacterSystemRequests::CreateCharacter, characterConfig, shapeConfig, *m_world); } ASSERT_TRUE(m_controller != nullptr); m_controller->SetBasePosition(AZ::Vector3::CreateZero()); } void Update(const AZ::Vector3& movementDelta, AZ::u32 numTimeSteps = 1) { for (AZ::u32 i = 0; i < numTimeSteps; i++) { m_controller->TryRelativeMove(movementDelta, m_timeStep); m_world->Update(m_timeStep); } } AZStd::shared_ptr<Physics::World> m_world; AZStd::shared_ptr<Physics::RigidBodyStatic> m_floor; AZStd::unique_ptr<Physics::Character> m_controller; float m_timeStep = 1.0f / 60.0f; }; Physics::ShapeType controllerShapeTypes[] = { Physics::ShapeType::Capsule, Physics::ShapeType::Box }; TEST_F(PhysXCharactersTest, CharacterController_UnimpededController_MovesAtDesiredVelocity) { ControllerTestBasis basis; AZ::Vector3 desiredVelocity = AZ::Vector3::CreateAxisX(); AZ::Vector3 movementDelta = desiredVelocity * basis.m_timeStep; for (int i = 0; i < 50; i++) { AZ::Vector3 basePosition = basis.m_controller->GetBasePosition(); EXPECT_TRUE(basePosition.IsClose(AZ::Vector3::CreateAxisX(basis.m_timeStep * i))); basis.Update(movementDelta); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(desiredVelocity)); } } TEST_F(PhysXCharactersTest, CharacterController_MovingDirectlyTowardsStaticBox_StoppedByBox) { ControllerTestBasis basis; AZ::Vector3 movementDelta = AZ::Vector3::CreateAxisX(basis.m_timeStep); auto box = Physics::AddStaticUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(1.5f, 0.0f, 0.5f)); // run the simulation for a while so the controller should get to the box and stop basis.Update(movementDelta, 50); // the edge of the box is at x = 1.0, we expect to stop a distance short of that given by the sum of the // capsule radius (0.25) and the contact offset (0.1) AZ::Vector3 basePosition = basis.m_controller->GetBasePosition(); EXPECT_TRUE(basePosition.IsClose(AZ::Vector3::CreateAxisX(0.65f))); // run the simulation some more and check that the controller is not moving in the direction of the box for (int i = 0; i < 10; i++) { AZ::Vector3 newBasePosition = basis.m_controller->GetBasePosition(); EXPECT_TRUE(newBasePosition.IsClose(basePosition)); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(AZ::Vector3::CreateZero())); basePosition = newBasePosition; basis.Update(movementDelta); } } TEST_F(PhysXCharactersTest, CharacterController_MovingDiagonallyTowardsStaticBox_SlidesAlongBox) { ControllerTestBasis basis; AZ::Vector3 movementDelta = AZ::Vector3(1.0f, 1.0f, 0.0f) * basis.m_timeStep; auto box = Physics::AddStaticUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(1.0f, 0.5f, 0.5f)); // run the simulation for a while so the controller should get to the box and start sliding basis.Update(movementDelta, 20); // the controller should be sliding in the y direction now for (int i = 0; i < 10; i++) { AZ::Vector3 velocity = basis.m_controller->GetVelocity(); float vx = velocity.GetX(); float vy = velocity.GetY(); EXPECT_NEAR(vx, 0.0f, 1e-3f); EXPECT_NEAR(vy, 1.0f, 1e-3f); basis.Update(movementDelta); } } TEST_F(PhysXCharactersTest, CharacterController_MovingOnSlope_CannotMoveAboveMaximumSlopeAngle) { // create a floor sloped at 30 degrees which should just be touching a controller with base position at the // origin, with radius + contact offset = 0.25 + 0.1 = 0.35 AZ::Transform slopedFloorTransform = AZ::Transform::CreateRotationY(-AZ::Constants::Pi / 6.0f); slopedFloorTransform.SetTranslation(AZ::Vector3::CreateAxisZ(0.35f) + slopedFloorTransform * AZ::Vector3::CreateAxisZ(-0.85f)); ControllerTestBasis basis(Physics::ShapeType::Capsule, slopedFloorTransform); // we should be able to travel at right angles to the slope AZ::Vector3 desiredVelocity = AZ::Vector3::CreateAxisY(); AZ::Vector3 movementDelta = desiredVelocity * basis.m_timeStep; for (int i = 0; i < 50; i++) { basis.Update(movementDelta); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(desiredVelocity)); } // we should slide if we try to travel diagonally up the slope as it is steeper than our maximum of 25 degrees desiredVelocity = AZ::Vector3(1.0f, 1.0f, 0.0f); movementDelta = desiredVelocity * basis.m_timeStep; // run a few frames to adjust to the change in direction basis.Update(movementDelta, 10); for (int i = 0; i < 50; i++) { basis.Update(movementDelta); AZ::Vector3 velocity = basis.m_controller->GetVelocity(); float vx = velocity.GetX(); float vy = velocity.GetY(); EXPECT_NEAR(vx, 0.0f, 1e-3f); EXPECT_NEAR(vy, 1.0f, 1e-3f); } // shouldn't be able to travel directly up the 30 degree slope as our maximum slope angle is 25 degrees desiredVelocity = AZ::Vector3(1.0f, 0.0f, 0.0f); movementDelta = desiredVelocity * basis.m_timeStep; for (int i = 0; i < 50; i++) { basis.Update(movementDelta); AZ::Vector3 velocity = basis.m_controller->GetVelocity(); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(AZ::Vector3::CreateZero())); } // should be able to move down the slope desiredVelocity = AZ::Vector3(-1.0f, 0.0f, -0.5f); movementDelta = desiredVelocity * basis.m_timeStep; // run a few frames to adjust to the change in direction basis.Update(movementDelta, 10); for (int i = 0; i < 50; i++) { basis.Update(movementDelta); AZ::Vector3 velocity = basis.m_controller->GetVelocity(); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(desiredVelocity)); } } TEST_F(PhysXCharactersTest, CharacterController_Steps_StoppedByTallStep) { ControllerTestBasis basis; auto shortStep = Physics::AddStaticUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(1.0f, 0.0f, -0.3f)); auto tallStep = Physics::AddStaticUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(2.0f, 0.0f, 0.5f)); AZ::Vector3 desiredVelocity = AZ::Vector3::CreateAxisX(); AZ::Vector3 movementDelta = desiredVelocity * basis.m_timeStep; for (int i = 0; i < 50; i++) { basis.Update(movementDelta); AZ::Vector3 velocity = basis.m_controller->GetVelocity(); float vx = velocity.GetX(); float vy = velocity.GetY(); EXPECT_NEAR(vx, 1.0f, 1e-3f); EXPECT_NEAR(vy, 0.0f, 1e-3f); } // expect the base of the controller to now be at the height of the short step (0.2) float expectedBaseHeight = 0.2f; float baseHeight = basis.m_controller->GetBasePosition().GetZ(); EXPECT_NEAR(baseHeight, expectedBaseHeight, 1e-3f); // after another 50 updates, we should have been stopped by the tall step basis.Update(movementDelta, 50); EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(AZ::Vector3::CreateZero())); baseHeight = basis.m_controller->GetBasePosition().GetZ(); EXPECT_NEAR(baseHeight, expectedBaseHeight, 1e-3f); } using CharacterControllerFixture = ::testing::TestWithParam<Physics::ShapeType>; TEST_P(CharacterControllerFixture, CharacterController_ResizedController_CannotFitUnderLowBox) { Physics::ShapeType shapeType = GetParam(); ControllerTestBasis basis(shapeType); // the bottom of the box will be at height 1.0 auto box = Physics::AddStaticUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(1.0f, 0.0f, 1.5f)); // resize the controller so that it is too tall to fit under the box auto controller = static_cast<PhysXCharacters::CharacterController*>(basis.m_controller.get()); controller->Resize(1.3f); EXPECT_NEAR(controller->GetHeight(), 1.3f, 1e-3f); const AZ::Vector3 desiredVelocity = AZ::Vector3::CreateAxisX(); const AZ::Vector3 movementDelta = desiredVelocity * basis.m_timeStep; basis.Update(movementDelta, 50); // movement should be impeded by the box because the controller is too tall to go under it EXPECT_TRUE(basis.m_controller->GetVelocity().IsClose(AZ::Vector3::CreateZero())); // resize the controller to a bit less tall than the height of the bottom of the box // leave some leeway under the box to account for the contact offset of the controller controller->Resize(0.6f); EXPECT_NEAR(controller->GetHeight(), 0.6f, 1e-3f); basis.Update(movementDelta, 50); // movement should now be unimpeded because the controller is short enough to go under the box const AZ::Vector3 velocity = basis.m_controller->GetVelocity(); const float vx = velocity.GetX(); const float vy = velocity.GetY(); EXPECT_NEAR(vx, 1.0f, 1e-3f); EXPECT_NEAR(vy, 0.0f, 1e-3f); } TEST_P(CharacterControllerFixture, CharacterController_ResizingToNegativeHeight_EmitsError) { Physics::ShapeType shapeType = GetParam(); ControllerTestBasis basis(shapeType); auto controller = static_cast<PhysXCharacters::CharacterController*>(basis.m_controller.get()); Physics::ErrorHandler errorHandler("PhysX requires controller height to be positive"); controller->Resize(-0.2f); EXPECT_EQ(errorHandler.GetErrorCount(), 1); } INSTANTIATE_TEST_CASE_P(PhysXCharacters, CharacterControllerFixture, ::testing::ValuesIn(controllerShapeTypes)); TEST_F(PhysXCharactersTest, CharacterController_ResizingCapsuleControllerBelowTwiceRadius_EmitsError) { ControllerTestBasis basis; auto controller = static_cast<PhysXCharacters::CharacterController*>(basis.m_controller.get()); // the controller will have been made with the default radius of 0.25, so any height under 0.5 should // be impossible Physics::ErrorHandler errorHandler("Capsule height must exceed twice its radius"); controller->Resize(0.45f); EXPECT_EQ(errorHandler.GetErrorCount(), 1); // the controller should still have the default height of 1 EXPECT_NEAR(controller->GetHeight(), 1.0f, 1e-3f); } TEST_F(PhysXCharactersTest, CharacterController_DroppingBox_CollidesWithController) { ControllerTestBasis basis; auto box = Physics::AddUnitBoxToWorld(basis.m_world.get(), AZ::Vector3(0.5f, 0.0f, 5.0f)); basis.Update(AZ::Vector3::CreateZero(), 200); // the box and controller have default collision layer and group so should collide // the box was positioned to land on its edge on the controller // so expect the box to have bounced off the controller and travelled in the x direction AZ::Vector3 boxPosition = box->GetPosition(); float x = boxPosition.GetX(); EXPECT_GT(x, 2.0f); } TEST_F(PhysXCharactersTest, CharacterController_RaycastAgainstController_ReturnsHit) { // raycast on an empty scene should return no hits Physics::RayCastRequest request; request.m_start = AZ::Vector3(-100.0f, 0.0f, 0.25f); request.m_direction = AZ::Vector3(1.0f, 0.0f, 0.0f); request.m_distance = 200.0f; Physics::RayCastHit hit; Physics::WorldRequestBus::BroadcastResult(hit, &Physics::WorldRequests::RayCast, request); EXPECT_FALSE(hit); // now add a controller and raycast again ControllerTestBasis basis; // the controller won't move to its initial position with its base at the origin until one update has happened basis.Update(AZ::Vector3::CreateZero()); Physics::WorldRequestBus::BroadcastResult(hit, &Physics::WorldRequests::RayCast, request); EXPECT_TRUE(hit); } TEST_F(PhysXCharactersTest, CharacterController_DeleteCharacterInsideTrigger_RaisesExitEvent) { // Create trigger Physics::ColliderConfiguration triggerConfig; triggerConfig.m_isTrigger = true; Physics::BoxShapeConfiguration boxConfig; boxConfig.m_dimensions = AZ::Vector3(10.0f, 10.0f, 10.0f); auto triggerEntity = AZStd::make_unique<AZ::Entity>("TriggerEntity"); triggerEntity->CreateComponent<AzFramework::TransformComponent>()->SetWorldTM(AZ::Transform::Identity()); triggerEntity->CreateComponent(PhysX::StaticRigidBodyComponentTypeId); Physics::SystemRequestBus::Broadcast(&Physics::SystemRequests::AddColliderComponentToEntity, triggerEntity.get(), triggerConfig, boxConfig, false); triggerEntity->Init(); triggerEntity->Activate(); // Create character auto characterConfiguration = AZStd::make_unique<Physics::CharacterConfiguration>(); auto characterShapeConfiguration = AZStd::make_unique<Physics::CapsuleShapeConfiguration>(); characterShapeConfiguration->m_height = 5.0f; characterShapeConfiguration->m_radius = 1.0f; auto characterEntity = AZStd::make_unique<AZ::Entity>("CharacterEntity"); characterEntity->CreateComponent<AzFramework::TransformComponent>()->SetWorldTM(AZ::Transform::Identity()); characterEntity->CreateComponent<PhysXCharacters::CharacterControllerComponent>(AZStd::move(characterConfiguration), AZStd::move(characterShapeConfiguration)); characterEntity->Init(); characterEntity->Activate(); // Update the world a bit to trigger Enter events for (int i = 0; i < 10; ++i) { GetDefaultWorld()->Update(0.1f); } // Delete the entity, and update the world to receive exit events characterEntity.reset(); GetDefaultWorld()->Update(0.1f); EXPECT_TRUE(m_triggerEnterEvents.size() == 1); EXPECT_TRUE(m_triggerExitEvents.size() == 1); } TEST_F(PhysXCharactersTest, CharacterController_DisabledPhysics_DoesNotCauseError_FT) { // given a character controller auto characterConfiguration = AZStd::make_unique<Physics::CharacterConfiguration>(); auto characterShapeConfiguration = AZStd::make_unique<Physics::CapsuleShapeConfiguration>(); characterShapeConfiguration->m_height = 5.0f; characterShapeConfiguration->m_radius = 1.0f; auto characterEntity = AZStd::make_unique<AZ::Entity>("CharacterEntity"); characterEntity->CreateComponent<AzFramework::TransformComponent>()->SetWorldTM(AZ::Transform::Identity()); characterEntity->CreateComponent<PhysXCharacters::CharacterControllerComponent>(AZStd::move(characterConfiguration), AZStd::move(characterShapeConfiguration)); characterEntity->Init(); characterEntity->Activate(); bool physicsEnabled = false; Physics::WorldBodyRequestBus::EventResult(physicsEnabled, characterEntity->GetId(), &Physics::WorldBodyRequestBus::Events::IsPhysicsEnabled); EXPECT_TRUE(physicsEnabled); // when physics is disabled Physics::WorldBodyRequestBus::Event(characterEntity->GetId(), &Physics::WorldBodyRequestBus::Events::DisablePhysics); Physics::WorldBodyRequestBus::EventResult(physicsEnabled, characterEntity->GetId(), &Physics::WorldBodyRequestBus::Events::IsPhysicsEnabled); EXPECT_FALSE(physicsEnabled); // expect no error occurs when sending common events AZ::Vector3 result; Physics::ErrorHandler errorHandler("Invalid character controller."); Physics::CharacterRequestBus::EventResult(result, characterEntity->GetId(), &Physics::CharacterRequestBus::Events::TryRelativeMove, AZ::Vector3::CreateZero(), 1.f); EXPECT_EQ(errorHandler.GetErrorCount(), 0); Physics::CharacterRequestBus::EventResult(result, characterEntity->GetId(), &Physics::CharacterRequestBus::Events::GetBasePosition); EXPECT_EQ(errorHandler.GetErrorCount(), 0); Physics::CharacterRequestBus::EventResult(result, characterEntity->GetId(), &Physics::CharacterRequestBus::Events::GetCenterPosition); EXPECT_EQ(errorHandler.GetErrorCount(), 0); Physics::CharacterRequestBus::EventResult(result, characterEntity->GetId(), &Physics::CharacterRequestBus::Events::GetVelocity); EXPECT_EQ(errorHandler.GetErrorCount(), 0); CharacterControllerRequestBus::Event(characterEntity->GetId(), &CharacterControllerRequestBus::Events::Resize, 2.f); EXPECT_EQ(errorHandler.GetErrorCount(), 0); float height = -1.f; CharacterControllerRequestBus::EventResult(height, characterEntity->GetId(), &CharacterControllerRequestBus::Events::GetHeight); EXPECT_EQ(errorHandler.GetErrorCount(), 0); AZ::TransformNotificationBus::Event(characterEntity->GetId(), &AZ::TransformNotificationBus::Events::OnTransformChanged, AZ::Transform::CreateIdentity(), AZ::Transform::CreateIdentity()); EXPECT_EQ(errorHandler.GetErrorCount(), 0); } TEST_F(PhysXCharactersTest, CharacterController_SetNoneCollisionGroupAfterCreation_DoesNotTrigger) { // Create character auto characterConfiguration = AZStd::make_unique<Physics::CharacterConfiguration>(); auto characterShapeConfiguration = AZStd::make_unique<Physics::CapsuleShapeConfiguration>(); characterShapeConfiguration->m_height = 1.0f; characterShapeConfiguration->m_radius = 1.0f; auto characterEntity = AZStd::make_unique<AZ::Entity>("CharacterEntity"); characterEntity->CreateComponent<AzFramework::TransformComponent>()->SetWorldTM(AZ::Transform::Identity()); characterEntity->CreateComponent<PhysXCharacters::CharacterControllerComponent>(AZStd::move(characterConfiguration), AZStd::move(characterShapeConfiguration)); characterEntity->Init(); characterEntity->Activate(); // Create unit box located near character, collides with character by default auto box = Physics::AddStaticUnitBoxToWorld(GetDefaultWorld().get(), AZ::Vector3(1.0f, 0.0f, 0.0f)); // Assign 'None' collision group to character controller - it should not collide with the box AZStd::string collisionGroupName; Physics::CollisionRequestBus::BroadcastResult(collisionGroupName, &Physics::CollisionRequests::GetCollisionGroupName, Physics::CollisionGroup::None); Physics::CollisionFilteringRequestBus::Event( characterEntity->GetId(), &Physics::CollisionFilteringRequests::SetCollisionGroup, collisionGroupName, AZ::Crc32()); // Try to move character in direction of the box const AZ::Vector3 deltaPosition(2.0f, 0.0f, 0.0f); AZ::Vector3 result; Physics::CharacterRequestBus::EventResult(result, characterEntity->GetId(), &Physics::CharacterRequestBus::Events::TryRelativeMove, deltaPosition, 1.0f); // With 'None' collision group assigned, character is expected to pass through the box to target position AZ::Vector3 characterTranslation = characterEntity->GetTransform()->GetWorldTranslation(); EXPECT_THAT(characterTranslation, UnitTest::IsClose(deltaPosition)); } } // namespace PhysXCharacters