Files
Destrum/tests/destrum_tests.cpp
T

1343 lines
57 KiB
C++

#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#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/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>
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 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 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();
}
}
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();
testRenderer2DShapes();
testFontAtlasAndLayout();
std::cout << "destrum tests passed\n";
return EXIT_SUCCESS;
}