#include #include #include #include #include #include #include #include #include #include #include #include #include #include "TestUtils.h" using namespace test; namespace { void testPhysicsWorldUnits() { JoltPhysicsWorld world{{ .workerThreadCount = 1, .gravity = glm::vec3{0.0f} }}; GameObject object{"scaled-physics"}; object.GetTransform().SetLocalScale(glm::vec3{0.5f}); object.AddComponent(1.0f); Rigidbody* rigidbody = object.AddComponent(); world.RegisterRigidbody(*rigidbody); PhysicsRaycastHit hit; check(world.Raycast({-2.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), "scaled physics bodies must remain raycastable"); check(close(hit.distance, 1.0f), "physics collider dimensions must remain in world units"); object.GetTransform().SetLocalPosition({10.0f, 0.0f, 0.0f}); world.RefreshRigidbody(*rigidbody); check(world.Raycast({8.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), "refreshing a physics body must use the current transform position"); check(close(hit.distance, 1.0f), "refreshed physics bodies must remain aligned with rendered transforms"); } void testJoltSphereCollisions() { JoltPhysicsWorld world{{ .workerThreadCount = 1, .gravity = {0.0f, -9.81f, 0.0f} }}; GameObject floor{"floor"}; floor.GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); floor.AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); auto* floorBody = floor.AddComponent(); floorBody->SetType(RigidbodyType::Static); world.RegisterRigidbody(*floorBody); GameObject first{"first"}; first.AddComponent(1.5f); auto* firstBody = first.AddComponent(); world.RegisterRigidbody(*firstBody); first.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); first.GetTransform().SetLocalScale(glm::vec3{0.015f}); world.RefreshRigidbody(*firstBody); GameObject second{"second"}; second.AddComponent(1.5f); auto* secondBody = second.AddComponent(); world.RegisterRigidbody(*secondBody); second.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); second.GetTransform().SetLocalScale(glm::vec3{0.015f}); world.RefreshRigidbody(*secondBody); for (int step = 0; step < 240; ++step) { world.Step(1.0f / 60.0f); world.SyncDynamicBodiesToTransforms(); } check(first.GetTransform().GetWorldPosition().y > 0.8f && second.GetTransform().GetWorldPosition().y > 0.8f, "Jolt spheres must collide with the ground plane"); check(glm::distance(first.GetTransform().GetWorldPosition(), second.GetTransform().GetWorldPosition()) > 2.5f, "Jolt spheres must collide with one another instead of occupying the same point"); } void testSceneJoltSphereCollisions() { Scene& scene = SceneManager::GetInstance().CreateScene("scene-physics"); GameObject* floor = scene.CreateGameObject("floor"); floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); auto* floorBody = floor->AddComponent(); floorBody->SetType(RigidbodyType::Static); GameObject* first = scene.CreateGameObject("first"); first->AddComponent(1.5f); first->AddComponent(); first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); first->GetTransform().SetWorldScale(glm::vec3{0.015f}); GameObject* second = scene.CreateGameObject("second"); second->AddComponent(1.5f); second->AddComponent(); second->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); second->GetTransform().SetWorldScale(glm::vec3{0.015f}); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*first); scene.GetPhysics().RefreshGameObject(*second); for (int step = 0; step < 240; ++step) { scene.FixedUpdate(1.0f / 60.0f); } check(first->GetTransform().GetWorldPosition().y > 0.8f && second->GetTransform().GetWorldPosition().y > 0.8f, "scene physics spheres must collide with the ground plane"); check(glm::distance(first->GetTransform().GetWorldPosition(), second->GetTransform().GetWorldPosition()) > 2.5f, "scene physics spheres must not overlap after settling"); SceneManager::GetInstance().Destroy(); } class TriggerCounter final : public Component { public: explicit TriggerCounter(GameObject& parent) : Component(parent, "TriggerCounter") {} std::string GetTypeName() const override { return "TriggerCounter"; } void OnTriggerEnter(GameObject*) override { ++enters; } void OnTriggerExit(GameObject*) override { ++exits; } int enters{0}; int exits{0}; }; void testNonPhysicsChangesKeepBodies() { Scene& scene = SceneManager::GetInstance().CreateScene("physics-refresh"); GameObject* trigger = scene.CreateGameObject("trigger"); trigger->AddComponent(glm::vec3{3.0f})->SetTrigger(true); trigger->AddComponent()->SetType(RigidbodyType::Static); auto* counter = trigger->AddComponent(); GameObject* crate = scene.CreateGameObject("crate"); crate->AddComponent(glm::vec3{0.5f}); auto* crateBody = crate->AddComponent(); crateBody->SetUseGravity(false); crateBody->SetAllowSleep(false); GameObject* lamp = scene.CreateGameObject("lamp"); auto* light = lamp->AddComponent(); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*trigger); scene.GetPhysics().RefreshGameObject(*crate); const auto step = [&scene] { for (int i = 0; i < 10; ++i) { scene.FixedUpdate(1.0f / 60.0f); } }; step(); check(counter->enters == 1 && counter->exits == 0, "a body inside a trigger must enter it once"); light->SetEnabled(false); light->SetEnabled(true); crate->AddComponent()->SetEnabled(false); step(); check(counter->enters == 1 && counter->exits == 0, "non physics components must not rebuild physics bodies"); crateBody->SetMass(5.0f); step(); check(counter->enters == 2 && counter->exits == 1, "rebuilding a body inside a trigger must exit the old body and enter the new one"); SceneManager::GetInstance().Destroy(); } void testKinematicBodiesMoveWithVelocity() { constexpr float fixedDt = 1.0f / 60.0f; Scene& scene = SceneManager::GetInstance().CreateScene("kinematic-velocity"); GameObject* platform = scene.CreateGameObject("platform"); platform->AddComponent(glm::vec3{2.0f, 0.25f, 2.0f}); auto* platformBody = platform->AddComponent(); platformBody->SetType(RigidbodyType::Kinematic); GameObject* crate = scene.CreateGameObject("crate"); crate->GetTransform().SetWorldPosition({0.0f, 0.76f, 0.0f}); crate->AddComponent(glm::vec3{0.5f}); auto* crateBody = crate->AddComponent(); crateBody->SetFriction(1.0f); crateBody->SetAllowSleep(false); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*platform); scene.GetPhysics().RefreshGameObject(*crate); for (int step = 0; step < 30; ++step) { scene.FixedUpdate(fixedDt); } check(glm::length(platformBody->GetLinearVelocity()) < 0.001f, "a kinematic body that is not moved must have no velocity"); constexpr float platformSpeed = 2.0f; for (int step = 0; step < 60; ++step) { platform->GetTransform().SetWorldPosition( platform->GetTransform().GetWorldPosition() + glm::vec3{platformSpeed * fixedDt, 0.0f, 0.0f}); scene.FixedUpdate(fixedDt); } check(close(platformBody->GetLinearVelocity().x, platformSpeed), "a kinematic body moved through its transform must get the matching velocity"); check(crate->GetTransform().GetWorldPosition().x > 1.5f, "a dynamic body resting on a moving kinematic platform must be carried along"); SceneManager::GetInstance().Destroy(); } void testColliderCenterOffset() { Scene& scene = SceneManager::GetInstance().CreateScene("collider-offset"); GameObject* box = scene.CreateGameObject("offset box"); box->AddComponent(glm::vec3{0.5f})->SetCenterOffset({0.0f, 5.0f, 0.0f}); box->AddComponent()->SetType(RigidbodyType::Static); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*box); PhysicsRaycastHit hit{}; const bool found = box->GetComponent()->GetPhysicsWorld()->Raycast( {0.0f, 10.0f, 0.0f}, {0.0f, -1.0f, 0.0f}, 20.0f, hit); check(found && std::abs(hit.distance - 4.5f) < 0.01f, "a collider center offset must move the shape, not just the center of mass"); SceneManager::GetInstance().Destroy(); } void testFrozenRotationCapsule() { Scene& scene = SceneManager::GetInstance().CreateScene("frozen-capsule"); GameObject* floor = scene.CreateGameObject("floor"); floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); floor->AddComponent()->SetType(RigidbodyType::Static); GameObject* character = scene.CreateGameObject("character"); character->AddComponent(0.4f, 1.8f); auto* body = character->AddComponent(); body->SetFriction(1.0f); body->SetFreezeRotation(true); character->GetTransform().SetWorldPosition({0.0f, 1.0f, 0.0f}); GameObject* trigger = scene.CreateGameObject("trigger"); trigger->GetTransform().SetWorldPosition({2.0f, 0.0f, 0.0f}); trigger->AddComponent(glm::vec3{2.0f})->SetTrigger(true); trigger->AddComponent()->SetType(RigidbodyType::Static); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*floor); scene.GetPhysics().RefreshGameObject(*character); scene.GetPhysics().RefreshGameObject(*trigger); // Sliding with friction would tip a free capsule over body->SetLinearVelocity({4.0f, 0.0f, 0.0f}); for (int step = 0; step < 180; ++step) { scene.FixedUpdate(1.0f / 60.0f); } const glm::vec3 position = character->GetTransform().GetWorldPosition(); const glm::quat rotation = character->GetTransform().GetWorldRotation(); check(std::abs(rotation.w) > 0.999f, "a capsule with frozen rotation must stay upright"); check(std::abs(position.y - (-0.5f + 0.9f)) < 0.05f, "a capsule must rest on top of the floor"); // Cast from inside the capsule (and the trigger it is standing in), like a ground check does PhysicsRaycastHit hit{}; const bool hitGround = body->GetPhysicsWorld()->Raycast( position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, hit, body->GetBody()); check(hitGround && hit.object == floor && hit.normal.y > 0.9f, "a ground ray must skip the caster and triggers and hit the floor"); PhysicsRaycastHit selfHit{}; check(body->GetPhysicsWorld()->Raycast( position - glm::vec3{0.0f, 0.7f, 0.0f}, glm::vec3{0.0f, -1.0f, 0.0f}, 0.3f, selfHit) && selfHit.object == character, "a ray that doesn't ignore the caster must hit it"); SceneManager::GetInstance().Destroy(); } class TriggerTracker final : public Component { public: explicit TriggerTracker(GameObject& owner) : Component(owner, "TriggerTracker") {} void Update(float) override {} std::string GetTypeName() const override { return "TriggerTracker"; } void OnTriggerEnter(GameObject* other) override { ++enterCount; lastOther = other; enterObjectName = other ? other->GetName() : "null"; } void OnTriggerExit(GameObject*) override { ++exitCount; } int enterCount{0}; int exitCount{0}; GameObject* lastOther{nullptr}; std::string enterObjectName; }; void testTriggerZone() { Scene& scene = SceneManager::GetInstance().CreateScene("trigger-test"); // Trigger box at origin (Static rigidbody + BoxCollider as trigger). GameObject* triggerObj = scene.CreateGameObject("TriggerZone"); triggerObj->AddComponent(glm::vec3{3.0f, 3.0f, 3.0f}); triggerObj->GetComponent()->SetTrigger(true); auto* triggerRb = triggerObj->AddComponent(); triggerRb->SetType(RigidbodyType::Static); auto* tracker = triggerObj->AddComponent(); // Falling sphere above the trigger. GameObject* sphere = scene.CreateGameObject("FallingSphere"); sphere->AddComponent(0.5f); sphere->AddComponent(); sphere->GetTransform().SetWorldPosition({0.0f, 8.0f, 0.0f}); scene.CommitPendingAdditions(); // Run physics for enough steps that the sphere falls into the trigger. for (int step = 0; step < 120; ++step) { scene.FixedUpdate(1.0f / 60.0f); } check(tracker->enterCount >= 1, "falling sphere must trigger OnTriggerEnter at least once"); check(tracker->exitCount >= 1, "falling sphere passing through trigger must exit"); check(tracker->enterObjectName == "FallingSphere", "OnTriggerEnter must report the correct entering object"); SceneManager::GetInstance().Destroy(); } } void RunPhysicsTests() { testPhysicsWorldUnits(); testJoltSphereCollisions(); testSceneJoltSphereCollisions(); testNonPhysicsChangesKeepBodies(); testKinematicBodiesMoveWithVelocity(); testColliderCenterOffset(); testFrozenRotationCapsule(); testTriggerZone(); }