1669 lines
73 KiB
C++
1669 lines
73 KiB
C++
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <unordered_set>
|
|
|
|
#include <glm/gtc/constants.hpp>
|
|
#include <glm/gtc/packing.hpp>
|
|
|
|
#include <destrum/Event.h>
|
|
#include <destrum/Assets/AssetManager.h>
|
|
#include <destrum/Assets/AssetReference.h>
|
|
#include <destrum/Assets/ModelFile.h>
|
|
#include <destrum/Assets/TextureFile.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/GlmJson.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/Physics/CapsuleCollider.h>
|
|
#include <destrum/Components/Physics/Collider.h>
|
|
#include <destrum/Components/Animator.h>
|
|
#include <destrum/Components/CameraComponent.h>
|
|
#include <destrum/Graphics/Resources/PrimitiveMeshes.h>
|
|
#include <destrum/Graphics/Managers/Renderer2D.h>
|
|
#include <destrum/Graphics/Resources/Font.h>
|
|
#include <glm/gtc/matrix_transform.hpp>
|
|
#include <destrum/Graphics/Frustum.h>
|
|
#include <destrum/Components/LightComponent.h>
|
|
#include <destrum/Components/Physics/Rigidbody.h>
|
|
#include <destrum/Graphics/Material.h>
|
|
#include <destrum/Graphics/imageLoader.h>
|
|
#include <destrum/Graphics/Managers/LineRenderingManager.h>
|
|
|
|
#include <components/Activatable.h>
|
|
#include <components/InteractableButton.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 TestActivatable final : public Activatable {
|
|
public:
|
|
explicit TestActivatable(GameObject& owner) : Activatable(owner, "TestActivatable") {}
|
|
|
|
std::string GetTypeName() const override { return "TestActivatable"; }
|
|
|
|
int activations{0};
|
|
|
|
protected:
|
|
void OnActivate(GameObject&) override { ++activations; }
|
|
};
|
|
|
|
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 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<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",
|
|
"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<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();
|
|
}
|
|
|
|
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<BoxCollider>(glm::vec3{3.0f})->SetTrigger(true);
|
|
trigger->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
|
auto* counter = trigger->AddComponent<TriggerCounter>();
|
|
|
|
GameObject* crate = scene.CreateGameObject("crate");
|
|
crate->AddComponent<BoxCollider>(glm::vec3{0.5f});
|
|
auto* crateBody = crate->AddComponent<Rigidbody>();
|
|
crateBody->SetUseGravity(false);
|
|
crateBody->SetAllowSleep(false);
|
|
|
|
GameObject* lamp = scene.CreateGameObject("lamp");
|
|
auto* light = lamp->AddComponent<LightComponent>();
|
|
|
|
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<LightComponent>()->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<BoxCollider>(glm::vec3{2.0f, 0.25f, 2.0f});
|
|
auto* platformBody = platform->AddComponent<Rigidbody>();
|
|
platformBody->SetType(RigidbodyType::Kinematic);
|
|
|
|
GameObject* crate = scene.CreateGameObject("crate");
|
|
crate->GetTransform().SetWorldPosition({0.0f, 0.76f, 0.0f});
|
|
crate->AddComponent<BoxCollider>(glm::vec3{0.5f});
|
|
auto* crateBody = crate->AddComponent<Rigidbody>();
|
|
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<float>()) &&
|
|
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<PlacedGlyph> 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<CapsuleCollider>(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<BoxCollider>(glm::vec3{0.5f})->SetCenterOffset({0.0f, 5.0f, 0.0f});
|
|
box->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
|
scene.CommitPendingAdditions();
|
|
scene.GetPhysics().RefreshGameObject(*box);
|
|
|
|
PhysicsRaycastHit hit{};
|
|
const bool found = box->GetComponent<Rigidbody>()->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<BoxCollider>(glm::vec3{10.0f, 0.5f, 10.0f});
|
|
floor->AddComponent<Rigidbody>()->SetType(RigidbodyType::Static);
|
|
|
|
GameObject* character = scene.CreateGameObject("character");
|
|
character->AddComponent<CapsuleCollider>(0.4f, 1.8f);
|
|
auto* body = character->AddComponent<Rigidbody>();
|
|
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<BoxCollider>(glm::vec3{2.0f})->SetTrigger(true);
|
|
trigger->AddComponent<Rigidbody>()->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<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 testLightComponentRoundTrip()
|
|
{
|
|
GameObject object{"light"};
|
|
auto* light = object.AddComponent<LightComponent>(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<LightComponent>();
|
|
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<CameraComponent>();
|
|
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<CameraComponent>();
|
|
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<glm::quat>(), .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<std::uint8_t>& 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<std::uint32_t>{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<std::string>{"root", "spine"} &&
|
|
loaded.skeleton.parentIndex == std::vector<int>{-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<std::string>{"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<std::uint8_t>(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<float> 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<std::uint8_t>& 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<BoxCollider>(glm::vec3{3.0f, 3.0f, 3.0f});
|
|
triggerObj->GetComponent<BoxCollider>()->SetTrigger(true);
|
|
auto* triggerRb = triggerObj->AddComponent<Rigidbody>();
|
|
triggerRb->SetType(RigidbodyType::Static);
|
|
auto* tracker = triggerObj->AddComponent<TriggerTracker>();
|
|
|
|
// Falling sphere above the trigger.
|
|
GameObject* sphere = scene.CreateGameObject("FallingSphere");
|
|
sphere->AddComponent<SphereCollider>(0.5f);
|
|
sphere->AddComponent<Rigidbody>();
|
|
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();
|
|
}
|
|
std::string readFile(const std::filesystem::path& path)
|
|
{
|
|
std::ifstream file(path, std::ios::binary);
|
|
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
|
}
|
|
|
|
void testSceneSaveRejectsNonFinite()
|
|
{
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("non-finite");
|
|
GameObject* object = scene.CreateGameObject("object");
|
|
scene.CommitPendingAdditions();
|
|
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json";
|
|
check(SceneSerializer::Save(scene, path), "a finite scene must save");
|
|
const std::string goodContents = readFile(path);
|
|
|
|
object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f});
|
|
check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save");
|
|
check(readFile(path) == goodContents, "a failed save must leave the previous file untouched");
|
|
check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"),
|
|
"a failed save must not leave its temp file behind");
|
|
|
|
std::filesystem::remove(path);
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testSceneValidationErrors()
|
|
{
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json";
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})";
|
|
|
|
const auto result = SceneSerializer::LoadSceneFile(path);
|
|
check(!result.success, "a scene with an unknown component must not load");
|
|
check(result.errorMessage.find("\"Lamp\"") != std::string::npos &&
|
|
result.errorMessage.find("NotAComponent") != std::string::npos &&
|
|
result.errorMessage.find("unknown component type") != std::string::npos,
|
|
"scene validation errors must name the object, component and problem");
|
|
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})";
|
|
const auto arrayData = SceneSerializer::LoadSceneFile(path);
|
|
check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos,
|
|
"component data that is not an object must be rejected with a reason");
|
|
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
void testSceneLoadRestoresBindings()
|
|
{
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("bindings");
|
|
int registered = 0;
|
|
int unregistered = 0;
|
|
scene.SetRegisterBindings([®istered] { ++registered; });
|
|
scene.SetUnregisterBindings([&unregistered] { ++unregistered; });
|
|
scene.Load();
|
|
const int registeredBefore = registered;
|
|
const int unregisteredBefore = unregistered;
|
|
scene.CreateGameObject("object");
|
|
scene.CommitPendingAdditions();
|
|
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json";
|
|
check(SceneSerializer::Save(scene, path), "the bindings scene must save");
|
|
check(SceneSerializer::Load(scene, path), "the bindings scene must load");
|
|
std::filesystem::remove(path);
|
|
|
|
check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again");
|
|
|
|
scene.SetRegisterBindings(nullptr);
|
|
scene.SetUnregisterBindings(nullptr);
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testSceneLoadDropsMissingCamera()
|
|
{
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("missing camera");
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json";
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})";
|
|
|
|
check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load");
|
|
check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored");
|
|
|
|
std::filesystem::remove(path);
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testGlmJson()
|
|
{
|
|
const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f};
|
|
check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array");
|
|
check(vec.get<glm::vec3>() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json");
|
|
|
|
const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f});
|
|
const nlohmann::json quat = rotation;
|
|
check(quat.size() == 4 && close(quat.at(3).get<float>(), rotation.w),
|
|
"quaternions must serialize as [x, y, z, w]");
|
|
check(quat.get<glm::quat>() == rotation, "quaternions must round-trip through json");
|
|
|
|
bool rejected = false;
|
|
try {
|
|
(void)nlohmann::json::array({1.0f, 2.0f}).get<glm::vec3>();
|
|
} catch (const std::exception&) {
|
|
rejected = true;
|
|
}
|
|
check(rejected, "a vec3 must reject arrays of the wrong size");
|
|
|
|
const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}};
|
|
check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} &&
|
|
data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f},
|
|
"json value() must work with glm defaults");
|
|
}
|
|
|
|
void testPrimitiveMeshKeys()
|
|
{
|
|
const glm::vec3 size{0.35f, 0.12f, 3.0f};
|
|
const std::string key = PrimitiveMeshes::BoxKey(size);
|
|
check(PrimitiveMeshes::IsPrimitiveKey(key), "box keys must be primitive keys");
|
|
check(PrimitiveMeshes::MakeBox(size).name == key, "boxes must be named by their key");
|
|
|
|
const auto rebuilt = PrimitiveMeshes::MakeFromKey(key + "#3");
|
|
check(rebuilt.has_value() && rebuilt->maxPos == size * 0.5f,
|
|
"a box must rebuild exactly from its key, ignoring the duplicate suffix");
|
|
check(PrimitiveMeshes::StripDuplicateSuffix("BulbMaterial#12") == "BulbMaterial" &&
|
|
PrimitiveMeshes::StripDuplicateSuffix("game://a.glb#mesh") == "game://a.glb#mesh",
|
|
"only numeric duplicate suffixes must be stripped");
|
|
check(!PrimitiveMeshes::MakeFromKey("prim://box?size=1,2").has_value() &&
|
|
!PrimitiveMeshes::MakeFromKey("prim://box?size=1,-2,3").has_value() &&
|
|
!PrimitiveMeshes::MakeFromKey("Box").has_value(),
|
|
"invalid primitive keys must not build a mesh");
|
|
}
|
|
|
|
void testSceneFileIdsAreLocal()
|
|
{
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_local_ids_scene.json";
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"currentCamera":2,"objects":[)"
|
|
R"({"id":1,"name":"root"},)"
|
|
R"({"id":2,"name":"child","parent":1,"components":[{"type":"CameraComponent"}]}]})";
|
|
|
|
Scene& first = SceneManager::GetInstance().CreateScene("local ids a");
|
|
Scene& second = SceneManager::GetInstance().CreateScene("local ids b");
|
|
check(SceneSerializer::Load(first, path), "the first copy of the scene must load");
|
|
check(SceneSerializer::Load(second, path), "the second copy of the scene must load");
|
|
std::filesystem::remove(path);
|
|
first.CommitPendingAdditions();
|
|
second.CommitPendingAdditions();
|
|
|
|
std::unordered_set<ObjectId> ids;
|
|
std::size_t count = 0;
|
|
for (const Scene* scene : {&first, &second}) {
|
|
for (const auto& object : scene->GetObjects()) {
|
|
ids.insert(object->GetId());
|
|
++count;
|
|
}
|
|
}
|
|
check(count == 4 && ids.size() == 4, "loading one file into two scenes must not duplicate ids");
|
|
|
|
for (const Scene* scene : {&first, &second}) {
|
|
const CameraComponent* camera = scene->GetCurrentCamera();
|
|
check(camera != nullptr && camera->GetGameObject()->GetName() == "child" &&
|
|
camera->GetGameObject()->GetScene() == scene,
|
|
"the current camera must be remapped to the object in its own scene");
|
|
const Transform* parent = camera->GetGameObject()->GetTransform().GetParent();
|
|
check(parent != nullptr && parent->GetOwner()->GetName() == "root" &&
|
|
parent->GetOwner()->GetScene() == scene,
|
|
"parents must be remapped within each loaded scene");
|
|
}
|
|
|
|
const GameObject* fresh = first.CreateGameObject("fresh");
|
|
check(!ids.contains(fresh->GetId()), "objects created after a load must get unused ids");
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
GameObject* findByName(const Scene& scene, const std::string& name)
|
|
{
|
|
for (const auto& object : scene.GetObjects()) {
|
|
if (object->GetName() == name) {
|
|
return object.get();
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void testButtonTargetsRoundTrip()
|
|
{
|
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
|
ComponentFactory::Register<TestActivatable>("TestActivatable");
|
|
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("button targets");
|
|
GameObject* door = scene.CreateGameObject("door");
|
|
door->AddComponent<TestActivatable>();
|
|
GameObject* lamp = scene.CreateGameObject("lamp");
|
|
lamp->AddComponent<TestActivatable>();
|
|
GameObject* buttonObject = scene.CreateGameObject("button");
|
|
auto* button = buttonObject->AddComponent<InteractableButton>();
|
|
button->SetCycleColors(false);
|
|
button->AddTarget(*door);
|
|
button->AddTarget(*lamp);
|
|
button->AddTarget(*door);
|
|
scene.CommitPendingAdditions();
|
|
check(button->GetTargets().size() == 2, "a button must not target the same object twice");
|
|
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_button_targets_scene.json";
|
|
check(SceneSerializer::Save(scene, path), "the button scene must save");
|
|
check(SceneSerializer::Load(scene, path), "the button scene must load");
|
|
|
|
Scene& copy = SceneManager::GetInstance().CreateScene("button targets copy");
|
|
check(SceneSerializer::Load(copy, path), "the button scene must load into a second scene");
|
|
std::filesystem::remove(path);
|
|
|
|
for (Scene* loaded : {&scene, ©}) {
|
|
loaded->CommitPendingAdditions();
|
|
GameObject* loadedButton = findByName(*loaded, "button");
|
|
auto* loadedComponent = loadedButton != nullptr ? loadedButton->GetComponent<InteractableButton>() : nullptr;
|
|
check(loadedComponent != nullptr, "the button must survive a round-trip");
|
|
|
|
const auto& targets = loadedComponent->GetTargets();
|
|
check(targets.size() == 2, "button targets must survive a round-trip");
|
|
GameObject* loadedDoor = targets[0].Get(*loaded);
|
|
GameObject* loadedLamp = targets[1].Get(*loaded);
|
|
check(loadedDoor == findByName(*loaded, "door") && loadedDoor != nullptr &&
|
|
loadedLamp == findByName(*loaded, "lamp") && loadedLamp != nullptr,
|
|
"button targets must point at the loaded objects in their own scene");
|
|
|
|
loadedComponent->Interact(*loadedButton);
|
|
check(loadedDoor->GetComponent<TestActivatable>()->activations == 1 &&
|
|
loadedLamp->GetComponent<TestActivatable>()->activations == 1,
|
|
"pressing a loaded button must activate its targets");
|
|
}
|
|
|
|
SceneManager::GetInstance().Destroy();
|
|
}
|
|
|
|
void testButtonDropsMissingTargets()
|
|
{
|
|
ComponentFactory::Register<InteractableButton>("InteractableButton");
|
|
|
|
Scene& scene = SceneManager::GetInstance().CreateScene("missing targets");
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_missing_targets_scene.json";
|
|
std::ofstream{path}
|
|
<< R"({"version":2,"objects":[{"id":5,"name":"button","components":)"
|
|
R"([{"type":"InteractableButton","data":{"cycleColors":false,"targets":[5,99]}}]}]})";
|
|
|
|
check(SceneSerializer::Load(scene, path), "a button with a dangling target must still load");
|
|
std::filesystem::remove(path);
|
|
scene.CommitPendingAdditions();
|
|
|
|
const GameObject* object = scene.GetObjects().front().get();
|
|
const auto* button = object->GetComponent<InteractableButton>();
|
|
check(button != nullptr && button->GetTargets().size() == 1 &&
|
|
button->GetTargets().front().GetId() == object->GetId(),
|
|
"dangling targets must be dropped and valid ones remapped");
|
|
|
|
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();
|
|
testSceneSaveRejectsNonFinite();
|
|
testSceneValidationErrors();
|
|
testSceneLoadRestoresBindings();
|
|
testSceneLoadDropsMissingCamera();
|
|
testGlmJson();
|
|
testPrimitiveMeshKeys();
|
|
testSceneFileIdsAreLocal();
|
|
testButtonTargetsRoundTrip();
|
|
testButtonDropsMissingTargets();
|
|
std::cout << "destrum tests passed\n";
|
|
return EXIT_SUCCESS;
|
|
}
|