#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { class TestComponent final : public Component { public: explicit TestComponent(GameObject& owner) : Component(owner, "TestComponent") {} void Update(float) override {} std::string GetTypeName() const override { return "TestComponent"; } }; class RemovingListener final : public EventListener { public: explicit RemovingListener(std::unique_ptr* victim) : victim(victim) {} void OnEvent(int) { if (victim != nullptr) { victim->reset(); victim = nullptr; } ++calls; } int calls{0}; private: std::unique_ptr* victim; }; void check(bool condition, const char* message) { if (!condition) { std::cerr << "FAILED: " << message << '\n'; std::exit(EXIT_FAILURE); } } bool close(float a, float b) { return std::abs(a - b) < 0.001f; } void testTransforms() { GameObject parent{"parent"}; GameObject child{"child"}; parent.GetTransform().SetLocalPosition({10.f, 2.f, -4.f}); parent.GetTransform().SetLocalRotation({0.f, 0.f, 90.f}); parent.GetTransform().SetLocalScale({2.f, 2.f, 2.f}); child.GetTransform().SetLocalPosition({1.f, 0.f, 0.f}); child.GetTransform().SetParent(&parent.GetTransform(), true); const auto worldPosition = child.GetTransform().GetWorldPosition(); check(close(worldPosition.x, 1.f) && close(worldPosition.y, 0.f), "reparenting must preserve world position"); child.GetTransform().SetWorldPosition({20.f, 10.f, 0.f}); const auto movedPosition = child.GetTransform().GetWorldPosition(); check(close(movedPosition.x, 20.f) && close(movedPosition.y, 10.f), "world position must account for parent rotation and scale"); parent.Destroy(); check(parent.IsBeingDestroyed() && child.IsBeingDestroyed(), "destroying a parent must mark descendants"); } void testSceneRoundTrip() { Scene& scene = SceneManager::GetInstance().CreateScene("round-trip"); GameObject* parent = scene.CreateGameObject("parent"); GameObject* child = scene.CreateGameObject("child"); child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f}); child->GetTransform().SetParent(&parent->GetTransform(), false); scene.CommitPendingAdditions(); SceneEnvironment& environment = scene.GetEnvironment(); environment.ambientColor = {0.2f, 0.3f, 0.4f}; environment.ambientIntensity = 2.0f; environment.fogColor = {0.6f, 0.7f, 0.8f}; environment.fogDensity = 0.05f; environment.skyboxRotationSpeed = 0.5f; environment.skyboxRotation = 1.0f; scene.Update(1.0f); check(close(environment.skyboxRotation, 1.5f), "scene updates must spin the skybox"); const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json"; check(SceneSerializer::Save(scene, path), "scene save must succeed"); check(SceneSerializer::Load(scene, path), "scene load must succeed"); std::filesystem::remove(path); check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects"); const auto* loadedChild = scene.GetObjects().at(1).get(); check(loadedChild->GetTransform().GetParent() != nullptr, "scene round-trip must preserve hierarchy"); check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f), "scene round-trip must preserve local transforms"); const SceneEnvironment& loadedEnvironment = scene.GetEnvironment(); check(loadedEnvironment.ambientColor == glm::vec3{0.2f, 0.3f, 0.4f} && close(loadedEnvironment.ambientIntensity, 2.0f) && loadedEnvironment.fogColor == glm::vec3{0.6f, 0.7f, 0.8f} && close(loadedEnvironment.fogDensity, 0.05f) && close(loadedEnvironment.skyboxRotationSpeed, 0.5f) && close(loadedEnvironment.skyboxRotation, 1.5f), "scene round-trip must preserve the environment settings"); SceneManager::GetInstance().Destroy(); } void testEventMutation() { Event event; int calls = 0; event.AddListener([&](int) { ++calls; }); event.AddListener([&](int) { ++calls; event.AddListener([&](int) { ++calls; }); }); event.Invoke(1); check(calls == 2, "event mutation must not invalidate the current invocation"); event.Invoke(1); check(calls == 5, "new event listeners must run on subsequent invocations"); } void testEventRemoval() { Event event; std::unique_ptr victim; RemovingListener remover{&victim}; victim = std::make_unique(nullptr); event.AddListener(&remover, &RemovingListener::OnEvent); event.AddListener(victim.get(), &RemovingListener::OnEvent); event.Invoke(1); check(remover.calls == 1, "event remover must run once"); check(victim == nullptr, "event listener must be removable during invocation"); } void testComponentRemoval() { Scene& scene = SceneManager::GetInstance().CreateScene("component-removal"); GameObject* object = scene.CreateGameObject("object"); object->AddComponent(); scene.CommitPendingAdditions(); check(object->GetComponent() != nullptr, "test component must be discoverable before removal"); check(object->DestroyComponent() != nullptr, "destroying a component must find it"); scene.CleanupDestroyedGameObjects(); check(object->GetComponent() == nullptr, "destroyed components must be removed at the scene boundary"); SceneManager::GetInstance().Destroy(); } void testEngineComponentRegistration() { RegisterEngineComponents(); const char* canonicalNames[] = { "MeshRendererComponent", "LightComponent", "Rotator", "Spinner", "OrbitAndSpin", "Animator", "Rigidbody", "BoxCollider", "SphereCollider", "CapsuleCollider" }; for (const char* name : canonicalNames) { check(ComponentFactory::IsRegistered(name), "all canonical engine component names must be registered"); } check(!ComponentFactory::IsRegistered("MeshRenderer"), "MeshRenderer legacy alias must not be registered"); check(!ComponentFactory::IsRegistered("RigidBody"), "RigidBody legacy alias must not be registered"); } void testLineRenderingManager() { auto& lineManager = LineRenderingManager::GetInstance(); lineManager.ClearLines(); lineManager.SubmitLine( glm::vec3{0.0f}, glm::vec3{1.0f, 0.0f, 0.0f}, glm::vec4{1.0f, 0.0f, 0.0f, 1.0f}); lineManager.SubmitLine(Line{ .start = glm::vec3{1.0f}, .end = glm::vec3{2.0f}, .color = glm::vec4{0.0f, 1.0f, 0.0f, 1.0f}, }); check(lineManager.GetLines().size() == 2, "line rendering manager must retain submitted lines"); lineManager.ClearLines(); check(lineManager.GetLines().empty(), "line rendering manager must clear transient lines"); } void testAssetPathValidation() { const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test"; std::filesystem::create_directories(root / "assets" / "engine"); std::filesystem::create_directories(root / "assets" / "game"); std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset"; std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked"; std::ofstream{root / "assets" / "engine" / "manifest.json"} << R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")), "valid virtual asset paths must resolve"); check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset", "manifest output paths must resolve to cooked assets"); bool rejected = false; try { (void)assetFs.GetFullPath("engine://../outside.txt"); } catch (const std::exception&) { rejected = true; } check(rejected, "asset traversal must be rejected"); const auto outside = root / "outside"; const auto link = root / "assets" / "engine" / "link"; std::filesystem::create_directories(outside); std::error_code symlinkError; std::filesystem::create_directory_symlink(outside, link, symlinkError); if (!symlinkError) { bool symlinkEscapeRejected = false; try { (void)assetFs.GetFullPath("engine://link/missing.txt"); } catch (const std::exception&) { symlinkEscapeRejected = true; } check(symlinkEscapeRejected, "symlink traversal must be rejected for missing targets"); } std::filesystem::remove_all(root); assetFs.Reset(); } void testLegacySceneLoad() { Scene& scene = SceneManager::GetInstance().CreateScene("legacy"); const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json"; std::ofstream{path} << R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})"; check(SceneSerializer::Load(scene, path), "version 1 placeholder transforms must remain loadable"); check(scene.GetObjects().size() == 1, "legacy scene load must create its object"); std::filesystem::remove(path); SceneManager::GetInstance().Destroy(); } void testSceneLoadRollback() { Scene& scene = SceneManager::GetInstance().CreateScene("rollback"); scene.CreateGameObject("original"); scene.CommitPendingAdditions(); const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json"; std::ofstream{path} << R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})"; check(!SceneSerializer::Load(scene, path), "scene loading must reject a rigidbody without a collider"); check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original", "failed scene loading must preserve the live scene"); std::filesystem::remove(path); SceneManager::GetInstance().Destroy(); } void testPhysicsWorldUnits() { SimplePhysicsWorld world{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(); } void testCapsuleColliderDebugLines() { GameObject object{"capsule"}; object.GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); object.GetTransform().SetWorldScale(glm::vec3{5.0f}); auto* capsule = object.AddComponent(0.4f, 1.8f); capsule->SetCenterOffset({0.0f, 0.5f, 0.0f}); auto& lines = LineRenderingManager::GetInstance(); lines.ClearLines(); capsule->DrawDebugLines(glm::vec4{1.0f}); const glm::vec3 center{1.0f, 2.5f, 3.0f}; float top = -1000.0f; float bottom = 1000.0f; bool inside = true; for (const Line& line : lines.GetLines()) { for (const glm::vec3& point : {line.start, line.end}) { const glm::vec3 local = point - center; top = std::max(top, local.y); bottom = std::min(bottom, local.y); inside = inside && glm::length(glm::vec2{local.x, local.z}) <= 0.4f + 0.001f; } } lines.ClearLines(); check(inside && close(top, 0.9f) && close(bottom, -0.9f), "capsule debug lines must match the collider shape in world units, ignoring the object scale"); } 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 testPrimitiveBoxMesh() { const glm::vec3 size{4.0f, 1.0f, 2.0f}; const CPUMesh box = PrimitiveMeshes::MakeBox(size); check(box.vertices.size() == 24 && box.indices.size() == 36, "a box must have 6 faces of 2 triangles"); check(box.minPos == -size * 0.5f && box.maxPos == size * 0.5f, "a box must be centered on the origin"); float maxU = 0.0f; bool outward = true; for (const auto& vertex : box.vertices) { if (vertex.normal == glm::vec3{0.0f, 0.0f, 1.0f}) { maxU = std::max(maxU, vertex.uv_x); } outward = outward && glm::dot(vertex.position, vertex.normal) > 0.0f; } check(close(maxU, size.x), "box uvs must be in meters so textures tile at the same size"); check(outward, "box normals must point away from the center"); bool counterClockwise = true; for (std::size_t i = 0; i < box.indices.size(); i += 3) { const auto& a = box.vertices[box.indices[i]]; const auto& b = box.vertices[box.indices[i + 1]]; const auto& c = box.vertices[box.indices[i + 2]]; const glm::vec3 faceNormal = glm::cross(b.position - a.position, c.position - a.position); counterClockwise = counterClockwise && glm::dot(faceNormal, a.normal) > 0.0f; } check(counterClockwise, "box triangles must be counter clockwise seen from outside"); } 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(); } void testAnimatorAssetReferences() { GameObject object{"animated"}; auto* animator = object.AddComponent(); Skeleton skeleton; Joint joint; joint.id = 0; joint.localTranslation = glm::vec3{0.0f}; joint.localRotation = glm::identity(); joint.localScale = glm::vec3{1.0f}; skeleton.joints.push_back(joint); skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f}); skeleton.jointNames.push_back("root"); skeleton.assetReference.path = "engine://character.fbx"; buildParentIndex(skeleton); animator->setSkeleton(std::move(skeleton)); auto clip = std::make_shared(); clip->name = "walk"; clip->assetReference.path = "engine://walk.fbx"; clip->assetReference.subresource = "animation:0"; clip->duration = 1.0f; SkeletalAnimation::Keyframe keyframe; keyframe.time = 0.0f; keyframe.translation = glm::vec3{0.0f}; keyframe.rotation = glm::identity(); keyframe.scale = glm::vec3{1.0f}; SkeletalAnimation::Track track; track.jointIndex = 0; track.keyframes.push_back(keyframe); clip->tracks.push_back(std::move(track)); animator->addClip(std::move(clip)); animator->play("walk"); const auto data = animator->Serialize(); check(data.contains("assetReferences"), "Animator scenes must store asset references"); check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx", "Animator serialization must store the skeleton source"); check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") == "animation:0", "Animator serialization must store the animation subresource"); check(data.dump().find("keyframes") == std::string::npos, "Animator scene data must not embed animation keyframes"); } void testLightComponentRoundTrip() { GameObject object{"light"}; auto* light = object.AddComponent(LightType::Spot); light->SetColor(glm::vec3{1.0f, 0.5f, 0.25f}); light->SetIntensity(4.0f); light->SetRange(7.5f); light->SetConeAngles(0.2f, 0.4f); light->SetCastShadows(true); const auto data = light->Serialize(); GameObject loadedObject{"loaded light"}; auto* loaded = loadedObject.AddComponent(); loaded->Deserialize(data); check(loaded->GetType() == LightType::Spot, "LightComponent must round-trip its type"); check(loaded->GetColor() == glm::vec3{1.0f, 0.5f, 0.25f}, "LightComponent must round-trip its color"); check(loaded->GetIntensity() == 4.0f && loaded->GetRange() == 7.5f, "LightComponent must round-trip intensity and range"); check(loaded->GetInnerConeAngle() == 0.2f && loaded->GetOuterConeAngle() == 0.4f, "LightComponent must round-trip its cone angles"); check(loaded->GetCastShadows(), "LightComponent must round-trip its cast shadows flag"); loaded->SetConeAngles(0.5f, 0.1f); check(loaded->GetOuterConeAngle() >= loaded->GetInnerConeAngle(), "LightComponent outer cone must never be smaller than the inner cone"); loaded->SetRange(0.0f); check(loaded->GetRange() > 0.0f, "LightComponent range must stay positive"); } void testSceneCurrentCamera() { Scene& scene = SceneManager::GetInstance().CreateScene("cameras"); check(scene.GetCurrentCamera() == nullptr, "a scene without cameras must have no current camera"); GameObject* first = scene.CreateGameObject("first camera"); auto* firstCamera = first->AddComponent(); scene.CommitPendingAdditions(); check(scene.GetCurrentCamera() == firstCamera, "the only camera in a scene must be the current camera"); GameObject* second = scene.CreateGameObject("second camera"); auto* secondCamera = second->AddComponent(); secondCamera->SetFieldOfView(60.0f); second->GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); scene.CommitPendingAdditions(); secondCamera->MakeCurrent(); check(scene.GetCurrentCamera() == secondCamera && !firstCamera->IsCurrent(), "the selected camera must be the current camera"); const Camera& camera = secondCamera->GetCamera(16.0f / 9.0f); check(camera.GetPosition() == glm::vec3{1.0f, 2.0f, 3.0f} && close(camera.GetForward().z, -1.0f) && camera.GetFOVY() == 60.0f, "a camera component must place its camera at its transform, looking down -Z"); const auto path = std::filesystem::temp_directory_path() / "destrum_camera_scene_test.json"; check(SceneSerializer::Save(scene, path), "scene with cameras must save"); check(SceneSerializer::Load(scene, path), "scene with cameras must load"); std::filesystem::remove(path); const CameraComponent* loadedCamera = scene.GetCurrentCamera(); check(loadedCamera != nullptr && loadedCamera->GetGameObject()->GetName() == "second camera" && loadedCamera->GetFieldOfView() == 60.0f, "scene round-trip must preserve the current camera"); scene.GetCurrentCamera()->GetGameObject()->SetActive(false); check(scene.GetCurrentCamera() != nullptr && scene.GetCurrentCamera()->GetGameObject()->GetName() == "first camera", "an inactive current camera must fall back to another camera"); SceneManager::GetInstance().Destroy(); } ModelDoc::ModelAsset makeTestModelAsset() { Skeleton skeleton; skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"}; skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}}; skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}}; skeleton.joints = { {.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}}, {.id = 1, .localScale = glm::vec3{2.0f}}, }; skeleton.jointNames = {"root", "spine"}; buildParentIndex(skeleton); CPUMesh mesh; mesh.indices = {0, 1, 2}; mesh.vertices = { {.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}}, {.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}}, {.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f}, }; mesh.skinningData = { {.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}}, {.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, {.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}}, }; mesh.name = "body"; mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"}; mesh.minPos = glm::vec3{0.0f}; mesh.maxPos = glm::vec3{1.0f}; mesh.skeleton = skeleton; ModelDoc::MeshPrimitive primitive; primitive.mesh = mesh; primitive.materialIndex = 0; primitive.nodeName = "Body"; primitive.sourceMeshIndex = 3; primitive.skinned = true; primitive.nodeWorldTransform = glm::mat4{3.0f}; ModelDoc::MaterialInfo material; material.name = "skin"; material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"}; material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f}; material.emissiveColor = glm::vec3{0.1f}; material.metallicFactor = 0.2f; material.roughnessFactor = 0.9f; material.opacity = 0.5f; material.textures = {{ .semantic = ModelDoc::TextureSemantic::BaseColor, .rawPath = "skin.png", .path = "textures/skin.png", .assimpType = aiTextureType_DIFFUSE, .slot = 1, .embedded = false, }}; SkeletalAnimation animation; animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"}; animation.tracks = {{ .jointIndex = 1, .keyframes = { {.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity(), .scale = glm::vec3{1.0f}}, {.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}}, }, }}; animation.duration = 0.5f; animation.looped = false; animation.name = "wave"; animation.startFrame = 4; animation.events = {{2, {"footstep", "dust"}}}; ModelDoc::ModelAsset model; model.sourcePath = "engine://character.fbx"; model.primitives = {primitive}; model.materials = {material}; model.skeleton = skeleton; model.animations = {animation}; return model; } bool modelFileReadThrows(const std::vector& bytes) { try { (void)ModelFile::Read(bytes); } catch (const std::exception&) { return true; } return false; } void testModelFileRoundTrip() { const auto bytes = ModelFile::Write(makeTestModelAsset()); const auto loaded = ModelFile::Read(bytes); check(ModelFile::Write(loaded) == bytes, "a cooked model must write back to exactly the same bytes"); check(loaded.sourcePath == "engine://character.fbx", "cooked models must keep their source path"); check(loaded.primitives.size() == 1, "cooked models must keep their primitives"); const auto& primitive = loaded.primitives.front(); check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned, "cooked primitives must keep their import metadata"); check(primitive.mesh.indices == std::vector{0, 1, 2}, "cooked meshes must keep their indices"); check(primitive.mesh.vertices.size() == 3 && primitive.mesh.vertices[0].uv_x == 0.25f && primitive.mesh.vertices[1].tangent.w == -1.0f, "cooked meshes must keep their vertices"); check(primitive.mesh.skinningData[0].jointIds.y == 1 && primitive.mesh.skinningData[0].weights.x == 0.5f, "cooked meshes must keep their skinning data"); check(primitive.mesh.skeleton.has_value() && primitive.mesh.skeleton->jointNames.size() == 2, "cooked skinned meshes must keep their skeleton"); check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f, "cooked models must keep their materials"); const auto& texture = loaded.materials.front().textures.front(); check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && texture.path == "textures/skin.png" && texture.assimpType == aiTextureType_DIFFUSE && texture.slot == 1 && !texture.embedded, "cooked materials must keep their textures"); check(loaded.skeleton.jointNames == std::vector{"root", "spine"} && loaded.skeleton.parentIndex == std::vector{-1, 0} && loaded.skeleton.joints[1].localScale == glm::vec3{2.0f}, "cooked models must keep their skeleton"); check(loaded.animations.size() == 1, "cooked models must keep their animations"); const auto& animation = loaded.animations.front(); check(animation.name == "wave" && !animation.looped && animation.startFrame == 4, "cooked animations must keep their settings"); check(animation.tracks.front().keyframes[1].translation.y == 2.0f && animation.tracks.front().keyframes[1].rotation.x == 1.0f, "cooked animations must keep their keyframes"); check(animation.events.at(2) == std::vector{"footstep", "dust"}, "cooked animations must keep their events"); } void testModelFileRejectsBadData() { const auto bytes = ModelFile::Write(makeTestModelAsset()); auto truncated = bytes; truncated.resize(bytes.size() / 2); check(modelFileReadThrows(truncated), "truncated cooked models must be rejected"); auto wrongMagic = bytes; wrongMagic[0] = 'X'; check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected"); auto wrongVersion = bytes; wrongVersion[4] = static_cast(wrongVersion[4] + 1); check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected"); auto trailingData = bytes; trailingData.push_back(0); check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected"); } void testModelFileFromImportedModel() { const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx"; const auto imported = ModelDoc::LoadModel(source.generic_string()); const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel"; ModelFile::WriteToFile(imported, path); const auto loaded = ModelFile::ReadFromFile(path); std::filesystem::remove(path); check(!imported.primitives.empty(), "the test model must import with at least one primitive"); check(loaded.primitives.size() == imported.primitives.size() && loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() && loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices, "an imported model must survive being cooked"); check(ModelFile::Write(loaded) == ModelFile::Write(imported), "a cooked imported model must match the import exactly"); } bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options) { try { (void)AssetManager::GetInstance().LoadModel(reference, options); } catch (const std::exception&) { return true; } return false; } void testAssetManagerLoadsCookedModels() { const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test"; const auto engineDir = root / "assets" / "engine"; std::filesystem::remove_all(root); std::filesystem::create_directories(engineDir); std::filesystem::create_directories(root / "assets" / "game"); // The source can't be imported, so a successful load has to come from the cooked file std::ofstream{engineDir / "model.fbx"} << "not a model"; ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel"); std::ofstream{engineDir / "manifest.json"} << R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx"); check(model.primitives.size() == 1 && model.animations.size() == 1, "cooked models must be loaded instead of importing the source"); check(model.sourcePath == "engine://model.fbx" && model.primitives.front().mesh.assetReference.path == "engine://model.fbx" && model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx", "cooked models must be referenced by their virtual path"); check(model.materials.front().textures.front().path == std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(), "cooked texture paths must be resolved next to the cooked model"); ModelDoc::LoadOptions withoutExtras{}; withoutExtras.loadMaterials = false; withoutExtras.loadAnimations = false; const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras); check(stripped.materials.empty() && stripped.animations.empty() && stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex, "cooked models must drop what the load options didn't ask for"); ModelDoc::LoadOptions withoutSkeleton{}; withoutSkeleton.loadSkeleton = false; check(loadModelThrows("engine://model.fbx", withoutSkeleton), "skinned cooked models must be imported when loaded without a skeleton"); ModelDoc::LoadOptions otherImport{}; otherImport.flipUVs = false; check(loadModelThrows("engine://model.fbx", otherImport), "cooked models must be imported when the import options differ from the cook options"); assetFs.Reset(); std::filesystem::remove_all(root); } TextureFile::Texture makeTestTexture() { const std::vector rgba{ 0.0f, 0.25f, 0.5f, 1.0f, 2.0f, 16.0f, 100.0f, 1.0f, }; return TextureFile::FromRGBA32Float(2, 1, rgba); } bool textureFileReadThrows(const std::vector& bytes) { try { (void)TextureFile::Read(bytes); } catch (const std::exception&) { return true; } return false; } void testTextureFileRoundTrip() { const auto texture = makeTestTexture(); check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float), "cooked textures must store every pixel"); std::uint64_t secondPixel = 0; std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel)); const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel); check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f}, "half float conversion must keep HDR values above 1"); const auto bytes = TextureFile::Write(texture); const auto loaded = TextureFile::Read(bytes); check(loaded.width == 2 && loaded.height == 1 && loaded.format == TextureFile::PixelFormat::RGBA16Float && loaded.pixels == texture.pixels, "cooked textures must round-trip"); auto truncated = bytes; truncated.resize(bytes.size() - 1); check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected"); auto wrongMagic = bytes; wrongMagic[0] = 'X'; check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected"); auto wrongSize = bytes; wrongSize[8] = 3; check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected"); } void testImageLoaderReadsCookedTextures() { const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex"; const auto texture = makeTestTexture(); TextureFile::WriteToFile(texture, path); const auto image = util::loadImage(path, TextureIntent::ColorSrgb); std::filesystem::remove(path); check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT && image.width == 2 && image.height == 1 && image.byteSize == texture.pixels.size(), "cooked textures must load as half float images"); check(image.pixels != nullptr && std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0, "cooked texture pixels must be uploaded as stored"); } void testSimpleColorMaterial() { const auto material = Material::SimpleColor( glm::vec3{0.2f, 0.4f, 0.8f}, "blue material"); check(material.baseColor == glm::vec3{0.2f, 0.4f, 0.8f}, "simple materials must preserve their color"); check(material.metallicFactor == 0.0f && material.roughnessFactor == 1.0f && material.emissiveColor == glm::vec3{0.0f}, "simple materials must use non-metallic default factors"); check(material.diffuseTexture == NULL_IMAGE_ID, "simple materials must not require a diffuse image"); check(material.name == "blue material", "simple materials must preserve their optional name"); } void testAssetReferenceCacheKeys() { const AssetReference reference{ .path = "game://CharacterMedium.fbx", .subresource = "mesh:0", }; const auto key = reference.cacheKey(); check(key == "game://CharacterMedium.fbx#mesh:0", "asset references must produce stable cache keys"); const auto parsed = AssetReference::fromCacheKey(key); check(parsed.has_value() && parsed->path == reference.path && parsed->subresource == reference.subresource, "asset cache keys must recover their source reference"); check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(), "plain cache names must not be treated as file-backed assets"); } 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* other) 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(); } } int main() { testTransforms(); testSceneRoundTrip(); testEventMutation(); testEventRemoval(); testComponentRemoval(); testEngineComponentRegistration(); testLineRenderingManager(); testAssetPathValidation(); testLegacySceneLoad(); testSceneLoadRollback(); testPhysicsWorldUnits(); testJoltSphereCollisions(); testSceneJoltSphereCollisions(); testAnimatorAssetReferences(); testLightComponentRoundTrip(); testModelFileRoundTrip(); testModelFileRejectsBadData(); testModelFileFromImportedModel(); testAssetManagerLoadsCookedModels(); testTextureFileRoundTrip(); testImageLoaderReadsCookedTextures(); testSimpleColorMaterial(); testAssetReferenceCacheKeys(); testTriggerZone(); testSceneCurrentCamera(); testFrozenRotationCapsule(); testPrimitiveBoxMesh(); testColliderCenterOffset(); testCapsuleColliderDebugLines(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }