509 lines
20 KiB
C++
509 lines
20 KiB
C++
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
#include <destrum/Event.h>
|
|
#include <destrum/Assets/AssetReference.h>
|
|
#include <destrum/FS/AssetFS.h>
|
|
#include <destrum/ObjectModel/Component.h>
|
|
#include <destrum/ObjectModel/GameObject.h>
|
|
#include <destrum/Scene/Scene.h>
|
|
#include <destrum/Scene/SceneManager.h>
|
|
#include <destrum/Serialization/ComponentFactory.h>
|
|
#include <destrum/Serialization/ComponentRegistry.h>
|
|
#include <destrum/Serialization/SceneSerializer.h>
|
|
#include <destrum/Physics/SimplePhysicsWorld.h>
|
|
#include <destrum/Physics/JoltPhysicsWorld.h>
|
|
#include <destrum/Components/Physics/SphereCollider.h>
|
|
#include <destrum/Components/Physics/BoxCollider.h>
|
|
#include <destrum/Components/Animator.h>
|
|
#include <destrum/Components/Physics/Rigidbody.h>
|
|
#include <destrum/Graphics/Material.h>
|
|
#include <destrum/Graphics/Managers/LineRenderingManager.h>
|
|
|
|
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<RemovingListener>* victim)
|
|
: victim(victim) {}
|
|
|
|
void OnEvent(int) {
|
|
if (victim != nullptr) {
|
|
victim->reset();
|
|
victim = nullptr;
|
|
}
|
|
++calls;
|
|
}
|
|
|
|
int calls{0};
|
|
|
|
private:
|
|
std::unique_ptr<RemovingListener>* 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();
|
|
|
|
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");
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testEventMutation()
|
|
{
|
|
Event<int> 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<int> event;
|
|
std::unique_ptr<RemovingListener> victim;
|
|
RemovingListener remover{&victim};
|
|
victim = std::make_unique<RemovingListener>(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<TestComponent>();
|
|
scene.CommitPendingAdditions();
|
|
|
|
check(object->GetComponent<TestComponent>() != nullptr,
|
|
"test component must be discoverable before removal");
|
|
check(object->DestroyComponent<TestComponent>() != nullptr,
|
|
"destroying a component must find it");
|
|
scene.CleanupDestroyedGameObjects();
|
|
check(object->GetComponent<TestComponent>() == nullptr,
|
|
"destroyed components must be removed at the scene boundary");
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testEngineComponentRegistration()
|
|
{
|
|
RegisterEngineComponents();
|
|
|
|
const char* canonicalNames[] = {
|
|
"MeshRendererComponent",
|
|
"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<SphereCollider>(1.0f);
|
|
Rigidbody* rigidbody = object.AddComponent<Rigidbody>();
|
|
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<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
|
auto* floorBody = floor.AddComponent<Rigidbody>();
|
|
floorBody->SetType(RigidbodyType::Static);
|
|
world.RegisterRigidbody(*floorBody);
|
|
|
|
GameObject first{"first"};
|
|
first.AddComponent<SphereCollider>(1.5f);
|
|
auto* firstBody = first.AddComponent<Rigidbody>();
|
|
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<SphereCollider>(1.5f);
|
|
auto* secondBody = second.AddComponent<Rigidbody>();
|
|
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<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
|
auto* floorBody = floor->AddComponent<Rigidbody>();
|
|
floorBody->SetType(RigidbodyType::Static);
|
|
|
|
GameObject* first = scene.CreateGameObject("first");
|
|
first->AddComponent<SphereCollider>(1.5f);
|
|
first->AddComponent<Rigidbody>();
|
|
first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f});
|
|
first->GetTransform().SetWorldScale(glm::vec3{0.015f});
|
|
|
|
GameObject* second = scene.CreateGameObject("second");
|
|
second->AddComponent<SphereCollider>(1.5f);
|
|
second->AddComponent<Rigidbody>();
|
|
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 testAnimatorAssetReferences()
|
|
{
|
|
GameObject object{"animated"};
|
|
auto* animator = object.AddComponent<Animator>();
|
|
|
|
Skeleton skeleton;
|
|
Joint joint;
|
|
joint.id = 0;
|
|
joint.localTranslation = glm::vec3{0.0f};
|
|
joint.localRotation = glm::identity<glm::quat>();
|
|
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<SkeletalAnimation>();
|
|
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<glm::quat>();
|
|
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 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.emissiveFactor == 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");
|
|
}
|
|
}
|
|
|
|
int main()
|
|
{
|
|
testTransforms();
|
|
testSceneRoundTrip();
|
|
testEventMutation();
|
|
testEventRemoval();
|
|
testComponentRemoval();
|
|
testEngineComponentRegistration();
|
|
testLineRenderingManager();
|
|
testAssetPathValidation();
|
|
testLegacySceneLoad();
|
|
testSceneLoadRollback();
|
|
testPhysicsWorldUnits();
|
|
testJoltSphereCollisions();
|
|
testSceneJoltSphereCollisions();
|
|
testAnimatorAssetReferences();
|
|
testSimpleColorMaterial();
|
|
testAssetReferenceCacheKeys();
|
|
std::cout << "destrum tests passed\n";
|
|
return EXIT_SUCCESS;
|
|
}
|