feat: Add Camera component / Add cooked model and texture loading

This commit is contained in:
2026-10-13 00:29:17 +02:00
parent fa98bf08e8
commit ce55f75ac3
26 changed files with 930 additions and 194 deletions
+187 -3
View File
@@ -1,14 +1,19 @@
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#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>
@@ -23,9 +28,11 @@
#include <destrum/Components/Physics/BoxCollider.h>
#include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Animator.h>
#include <destrum/Components/CameraComponent.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 {
@@ -486,6 +493,48 @@ namespace {
"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;
@@ -535,11 +584,11 @@ namespace {
material.opacity = 0.5f;
material.textures = {{
.semantic = ModelDoc::TextureSemantic::BaseColor,
.rawPath = "*0",
.rawPath = "skin.png",
.path = "textures/skin.png",
.assimpType = aiTextureType_DIFFUSE,
.slot = 1,
.embedded = true,
.embedded = false,
}};
SkeletalAnimation animation;
@@ -610,7 +659,8 @@ namespace {
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
texture.path == "textures/skin.png" &&
texture.assimpType == aiTextureType_DIFFUSE &&
texture.embedded,
texture.slot == 1 &&
!texture.embedded,
"cooked materials must keep their textures");
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
@@ -669,6 +719,136 @@ namespace {
"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(
@@ -787,9 +967,13 @@ int main()
testModelFileRoundTrip();
testModelFileRejectsBadData();
testModelFileFromImportedModel();
testAssetManagerLoadsCookedModels();
testTextureFileRoundTrip();
testImageLoaderReadsCookedTextures();
testSimpleColorMaterial();
testAssetReferenceCacheKeys();
testTriggerZone();
testSceneCurrentCamera();
std::cout << "destrum tests passed\n";
return EXIT_SUCCESS;
}