#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 #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 testSceneFindGameObject() { Scene& scene = SceneManager::GetInstance().CreateScene("find-object"); GameObject* pending = scene.CreateGameObject("pending"); check(scene.FindGameObject(pending->GetId()) == pending, "objects must be found while still pending"); scene.CommitPendingAdditions(); GameObject* committed = scene.CreateGameObject("committed"); scene.CommitPendingAdditions(); check(scene.FindGameObject(committed->GetId()) == committed, "committed objects must be found by id"); check(scene.FindGameObject(InvalidObjectId) == nullptr, "the invalid id must never match"); const ObjectId destroyedId = committed->GetId(); committed->Destroy(); check(scene.FindGameObject(destroyedId) == nullptr, "objects being destroyed must not be found"); SceneManager::GetInstance().Destroy(); } 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(); } 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 testFrustumFromViewProj() { const auto lookingAlongPositiveZ = [](const glm::vec3& position, float range) { const glm::mat4 view = glm::lookAt(position, position + glm::vec3{0.0f, 0.0f, 1.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); return glm::perspectiveRH_ZO(glm::radians(60.0f), 1.0f, 0.1f, range) * view; }; const glm::mat4 viewProj = lookingAlongPositiveZ(glm::vec3{0.0f}, 20.0f); const Frustum frustum = edge::createFrustumFromViewProj(viewProj); check(edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 10.0f}, 0.5f}), "a sphere in front must be inside"); check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, -5.0f}, 0.5f}), "a sphere behind must be outside"); check(!edge::isInFrustum(frustum, Sphere{{0.0f, 0.0f, 30.0f}, 0.5f}), "a sphere past the far plane must be outside"); check(!edge::isInFrustum(frustum, Sphere{{15.0f, 0.0f, 10.0f}, 0.5f}), "a sphere to the side must be outside"); const auto corners = edge::calculateFrustumCorners(viewProj); const bool cornersOnNearAndFar = std::ranges::all_of(corners, [](const glm::vec3& corner) { return std::abs(corner.z - 0.1f) < 0.001f || std::abs(corner.z - 20.0f) < 0.01f; }); check(cornersOnNearAndFar, "frustum corners must lie on the near and far planes"); check(edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, 5.0f}, 10.0f)), "a frustum in front of the camera must overlap"); check(!edge::frustumsOverlap(frustum, lookingAlongPositiveZ(glm::vec3{0.0f, 0.0f, -30.0f}, 10.0f)), "a frustum entirely behind the camera must not overlap"); } 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 testRenderer2DShapes() { auto& r2d = Renderer2D::GetInstance(); r2d.Clear(); r2d.DrawRect({10.0f, 20.0f}, {30.0f, 40.0f}, glm::vec4{0.5f}); const Shape2D& rect = r2d.GetShapes().back(); check(rect.type == Shape2DType::Rect && rect.center == glm::vec2{25.0f, 40.0f} && rect.halfSize == glm::vec2{15.0f, 20.0f} && rect.textureId == NULL_IMAGE_ID && rect.outline == 0.0f, "rects must be anchored at their top left corner and filled by default"); r2d.DrawEllipseOutline({50.0f, 50.0f}, {20.0f, 10.0f}, 2.0f, glm::vec4{1.0f}); const Shape2D& ellipse = r2d.GetShapes().back(); check(ellipse.type == Shape2DType::Ellipse && ellipse.center == glm::vec2{50.0f} && ellipse.halfSize == glm::vec2{20.0f, 10.0f} && ellipse.outline == 2.0f, "ellipses must be centered with their radii as half size"); r2d.DrawTriangle({0.0f, 10.0f}, {5.0f, 0.0f}, {10.0f, 10.0f}, glm::vec4{1.0f}); const Shape2D& triangle = r2d.GetShapes().back(); check(triangle.type == Shape2DType::Triangle && triangle.center == glm::vec2{5.0f} && triangle.halfSize == glm::vec2{5.0f} && triangle.p0 == glm::vec2{-5.0f, 5.0f} && triangle.p1 == glm::vec2{0.0f, -5.0f} && triangle.p2 == glm::vec2{5.0f, 5.0f}, "triangle points must be stored relative to their bounding box center"); r2d.DrawLine({0.0f, 0.0f}, {0.0f, 10.0f}, 4.0f, glm::vec4{1.0f}, LineCap::Round); const Shape2D& line = r2d.GetShapes().back(); check(line.type == Shape2DType::Rect && line.center == glm::vec2{0.0f, 5.0f} && close(line.halfSize.x, 7.0f) && close(line.halfSize.y, 2.0f) && close(line.rotation, glm::half_pi()) && close(line.cornerRadius, 2.0f), "round capped lines must be rotated rects extended by half their thickness"); check(r2d.GetShapes().size() == 4, "every draw call must submit exactly one shape"); r2d.Clear(); check(r2d.GetShapes().empty(), "clear must drop all submitted shapes"); } void testFontAtlasAndLayout() { const std::filesystem::path fontPath = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "fonts" / "Roboto-Medium.ttf"; const Font font = Font::LoadFromFile(fontPath, 32.0f); const FontGlyph* a = font.GetGlyph(U'A'); const FontGlyph* space = font.GetGlyph(U' '); check(a != nullptr && a->size.x > 0.0f && a->size.y > 0.0f && a->advance > 0.0f && a->uvMax.x > a->uvMin.x && a->uvMax.y > a->uvMin.y, "printable glyphs must be rasterized into the atlas"); check(space != nullptr && space->size == glm::vec2{0.0f} && space->advance > 0.0f, "spaces must only advance the pen"); check(font.GetGlyph(U'中') == font.GetGlyph(U'?'), "missing glyphs must fall back to '?'"); const auto& pixels = font.GetAtlasPixels(); const auto [minIt, maxIt] = std::minmax_element(pixels.begin(), pixels.end()); check(!pixels.empty() && *minIt < 128 && *maxIt > 128, "the atlas must hold a signed distance field with the edge at 128"); const glm::vec2 one = font.MeasureText("A", 64.0f); const glm::vec2 two = font.MeasureText("A\nAA", 64.0f); check(close(one.x, a->advance * 2.0f) && close(one.y, font.GetLineHeight() * 2.0f), "text must be measured at the requested size"); check(close(two.x, (a->advance * 2.0f + font.GetKerning(U'A', U'A')) * 2.0f) && close(two.y, one.y * 2.0f), "multi line text must measure its widest line and every line"); std::vector placed; font.Layout("A b", {100.0f, 50.0f}, 32.0f, [&](const PlacedGlyph& glyph) { placed.push_back(glyph); }); check(placed.size() == 2 && placed[0].glyph == a && close(placed[0].topLeft.x, 100.0f + a->offset.x) && close(placed[0].topLeft.y, 50.0f + font.GetAscender() + a->offset.y), "layout must skip spaces and start on the baseline below the top left"); const std::string_view utf8 = "\xC3\xA9\xFF"; std::size_t index = 0; check(Font::DecodeUtf8(utf8, index) == U'é' && index == 2, "utf8 must decode two byte sequences"); check(Font::DecodeUtf8(utf8, index) == 0xFFFD && index == 3, "invalid utf8 must decode to U+FFFD"); auto& r2d = Renderer2D::GetInstance(); r2d.Clear(); r2d.DrawText(font, "not uploaded", {0.0f, 0.0f}, 16.0f, glm::vec4{1.0f}); check(r2d.GetShapes().empty(), "text must not be drawn before the font atlas is uploaded"); } 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(); testSceneFindGameObject(); testSceneRoundTrip(); testEventMutation(); testEventRemoval(); testComponentRemoval(); testEngineComponentRegistration(); testLineRenderingManager(); testAssetPathValidation(); testLegacySceneLoad(); testSceneLoadRollback(); testPhysicsWorldUnits(); testJoltSphereCollisions(); testSceneJoltSphereCollisions(); testNonPhysicsChangesKeepBodies(); testAnimatorAssetReferences(); testLightComponentRoundTrip(); testModelFileRoundTrip(); testModelFileRejectsBadData(); testModelFileFromImportedModel(); testAssetManagerLoadsCookedModels(); testTextureFileRoundTrip(); testImageLoaderReadsCookedTextures(); testSimpleColorMaterial(); testAssetReferenceCacheKeys(); testTriggerZone(); testSceneCurrentCamera(); testFrozenRotationCapsule(); testPrimitiveBoxMesh(); testColliderCenterOffset(); testCapsuleColliderDebugLines(); testFrustumFromViewProj(); testKinematicBodiesMoveWithVelocity(); testRenderer2DShapes(); testFontAtlasAndLayout(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }