From ce55f75ac3c2874f8542c4bab88310c59c2c0e46 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Tue, 13 Oct 2026 00:29:17 +0200 Subject: [PATCH] feat: Add Camera component / Add cooked model and texture loading --- .gitignore | 2 +- TheChef | 2 +- destrum/CMakeLists.txt | 6 +- destrum/include/destrum/Assets/AssetManager.h | 4 + destrum/include/destrum/Assets/ModelFile.h | 8 + destrum/include/destrum/Assets/TextureFile.h | 38 ++++ .../destrum/Components/CameraComponent.h | 44 ++++ destrum/include/destrum/Graphics/Camera.h | 2 + .../include/destrum/Graphics/imageLoader.h | 7 +- destrum/include/destrum/Scene/Scene.h | 7 + .../Serialization/EngineComponentList.h | 2 + destrum/include/destrum/Util/ImGuiUtils.h | 2 + destrum/src/Assets/AssetManager.cpp | 55 ++++- destrum/src/Assets/BinaryStream.h | 206 +++++++++++++++++ destrum/src/Assets/ModelFile.cpp | 208 ++++-------------- destrum/src/Assets/TextureFile.cpp | 90 ++++++++ destrum/src/Components/CameraComponent.cpp | 90 ++++++++ destrum/src/Graphics/Camera.cpp | 9 + destrum/src/Graphics/ImageLoader.cpp | 48 +++- destrum/src/Scene/Scene.cpp | 30 +++ destrum/src/Serialization/SceneSerializer.cpp | 4 + destrum/src/Util/ImGuiUtils.cpp | 10 + lightkeeper/CMakeLists.txt | 4 - lightkeeper/include/Lightkeeper.h | 3 + lightkeeper/src/Lightkeeper.cpp | 53 ++++- tests/destrum_tests.cpp | 190 +++++++++++++++- 26 files changed, 930 insertions(+), 194 deletions(-) create mode 100644 destrum/include/destrum/Assets/TextureFile.h create mode 100644 destrum/include/destrum/Components/CameraComponent.h create mode 100644 destrum/src/Assets/BinaryStream.h create mode 100644 destrum/src/Assets/TextureFile.cpp create mode 100644 destrum/src/Components/CameraComponent.cpp diff --git a/.gitignore b/.gitignore index 5caffa1..20ba34c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ lightkeeper/assets_runtime build/** -# Claude Code CLAUDE.md +docs/** diff --git a/TheChef b/TheChef index 38230f5..0273340 160000 --- a/TheChef +++ b/TheChef @@ -1 +1 @@ -Subproject commit 38230f509279ec3c2385ad9660dc0286868045b5 +Subproject commit 0273340e3b086bf27192ae0f3019a3c2ff68b83d diff --git a/destrum/CMakeLists.txt b/destrum/CMakeLists.txt index 1a5e3f1..cc18284 100644 --- a/destrum/CMakeLists.txt +++ b/destrum/CMakeLists.txt @@ -3,6 +3,7 @@ add_subdirectory(third_party) # CPU side asset types and the cooked model format, shared with TheChef (no Vulkan / SDL dependencies) add_library(destrum_assets "src/Assets/ModelFile.cpp" + "src/Assets/TextureFile.cpp" ) add_library(destrum::assets ALIAS destrum_assets) @@ -35,6 +36,7 @@ set(SRC_FILES "src/Event.cpp" "src/Components/Animator.cpp" + "src/Components/CameraComponent.cpp" "src/Components/LightComponent.cpp" "src/Components/MeshRendererComponent.cpp" "src/Components/Rotator.cpp" @@ -192,10 +194,6 @@ add_custom_target(_internal_clean_engine_assets ) add_custom_target(_internal_cook_engine_assets - COMMAND TheChef - --input "${ASSETS_SRC_DIR}" - --output "${ASSETS_RUNTIME_DIR}" - --clean COMMAND TheChef --input "${ASSETS_SRC_DIR}" --output "${ASSETS_RUNTIME_DIR}" diff --git a/destrum/include/destrum/Assets/AssetManager.h b/destrum/include/destrum/Assets/AssetManager.h index 8e4c3da..cd8634b 100644 --- a/destrum/include/destrum/Assets/AssetManager.h +++ b/destrum/include/destrum/Assets/AssetManager.h @@ -28,6 +28,10 @@ public: private: AssetManager() = default; + + [[nodiscard]] ModelDoc::ModelAsset LoadCookedOrImport( + const std::string& reference, + const ModelDoc::LoadOptions& options) const; }; #endif // DESTRUM_ASSETMANAGER_H diff --git a/destrum/include/destrum/Assets/ModelFile.h b/destrum/include/destrum/Assets/ModelFile.h index 23b2547..72a39a2 100644 --- a/destrum/include/destrum/Assets/ModelFile.h +++ b/destrum/include/destrum/Assets/ModelFile.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -13,11 +14,18 @@ namespace ModelFile { inline constexpr std::uint32_t Version = 1; inline constexpr std::string_view Extension = ".dmodel"; + inline constexpr ModelDoc::LoadOptions CookOptions{}; + + [[nodiscard]] bool SupportsLoadOptions(const ModelDoc::ModelAsset& model, const ModelDoc::LoadOptions& options); + void ApplyLoadOptions(ModelDoc::ModelAsset& model, const ModelDoc::LoadOptions& options); + [[nodiscard]] std::vector Write(const ModelDoc::ModelAsset& model); [[nodiscard]] ModelDoc::ModelAsset Read(std::span bytes); void WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path); [[nodiscard]] ModelDoc::ModelAsset ReadFromFile(const std::filesystem::path& path); + + [[nodiscard]] std::optional ReadVersionFromFile(const std::filesystem::path& path); } #endif // DESTRUM_MODELFILE_H diff --git a/destrum/include/destrum/Assets/TextureFile.h b/destrum/include/destrum/Assets/TextureFile.h new file mode 100644 index 0000000..1c6d372 --- /dev/null +++ b/destrum/include/destrum/Assets/TextureFile.h @@ -0,0 +1,38 @@ +#ifndef DESTRUM_TEXTUREFILE_H +#define DESTRUM_TEXTUREFILE_H + +#include +#include +#include +#include +#include +#include + +namespace TextureFile { + inline constexpr std::uint32_t Version = 1; + inline constexpr std::string_view Extension = ".dtex"; + + enum class PixelFormat : std::uint32_t { + RGBA16Float, + }; + + struct Texture { + std::uint32_t width{0}; + std::uint32_t height{0}; + PixelFormat format{PixelFormat::RGBA16Float}; + std::vector pixels; + }; + + [[nodiscard]] std::size_t BytesPerPixel(PixelFormat format); + + [[nodiscard]] Texture FromRGBA32Float(std::uint32_t width, std::uint32_t height, std::span rgba); + + [[nodiscard]] std::vector Write(const Texture& texture); + [[nodiscard]] Texture Read(std::span bytes); + + void WriteToFile(const Texture& texture, const std::filesystem::path& path); + [[nodiscard]] Texture ReadFromFile(const std::filesystem::path& path); + [[nodiscard]] std::optional ReadVersionFromFile(const std::filesystem::path& path); +} + +#endif // DESTRUM_TEXTUREFILE_H diff --git a/destrum/include/destrum/Components/CameraComponent.h b/destrum/include/destrum/Components/CameraComponent.h new file mode 100644 index 0000000..5d631df --- /dev/null +++ b/destrum/include/destrum/Components/CameraComponent.h @@ -0,0 +1,44 @@ +#ifndef CAMERACOMPONENT_H +#define CAMERACOMPONENT_H + +#include +#include + +#include + +class CameraComponent final : public Component { +public: + explicit CameraComponent(GameObject& parent); + + CameraComponent(const CameraComponent& other) = delete; + CameraComponent(CameraComponent&& other) noexcept = delete; + CameraComponent& operator=(const CameraComponent& other) = delete; + CameraComponent& operator=(CameraComponent&& other) noexcept = delete; + + std::string GetTypeName() const override { return "CameraComponent"; } + + nlohmann::json Serialize() const override; + void Deserialize(const nlohmann::json& data) override; + void ImGuiInspector() override; + + // Places the camera at this object's world transform, looking down its local -Z. + [[nodiscard]] Camera& GetCamera(float aspectRatio); + + void SetFieldOfView(float fovDegrees); + [[nodiscard]] float GetFieldOfView() const { return m_FieldOfView; } + + void SetClipPlanes(float zNear, float zFar); + [[nodiscard]] float GetZNear() const { return m_ZNear; } + [[nodiscard]] float GetZFar() const { return m_ZFar; } + + void MakeCurrent(); + [[nodiscard]] bool IsCurrent() const; + +private: + Camera m_Camera{glm::vec3{0.0f}, glm::vec3{0.0f, 1.0f, 0.0f}}; + float m_FieldOfView{90.0f}; + float m_ZNear{0.01f}; + float m_ZFar{1000.0f}; +}; + +#endif // CAMERACOMPONENT_H diff --git a/destrum/include/destrum/Graphics/Camera.h b/destrum/include/destrum/Graphics/Camera.h index e4c77c0..ae88b3d 100644 --- a/destrum/include/destrum/Graphics/Camera.h +++ b/destrum/include/destrum/Graphics/Camera.h @@ -101,6 +101,7 @@ public: float GetZFar() const { return m_zFar; } void SetZFar(float zFar) { m_zFar = zFar; } float GetFOVY() const { return fovAngle; } + void SetFOVY(float fovDegrees) { fovAngle = fovDegrees; } float GetAspectRatio() const { return m_aspectRatio; } glm::vec3 m_position{2.f, 0, 0}; @@ -127,6 +128,7 @@ public: void SetRotation(float yawRadians, float pitchRadians); void SetRotation(const glm::vec2& yawPitchRadians); + void SetOrientation(const glm::vec3& forward, const glm::vec3& up); float GetYaw() const { return m_yaw; } float GetPitch() const { return m_pitch; } diff --git a/destrum/include/destrum/Graphics/imageLoader.h b/destrum/include/destrum/Graphics/imageLoader.h index 8cf715d..9a97935 100644 --- a/destrum/include/destrum/Graphics/imageLoader.h +++ b/destrum/include/destrum/Graphics/imageLoader.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "destrum/Graphics/TextureIntent.h" @@ -19,11 +20,12 @@ struct ImageData { if (this == &o) return *this; // free current contents first - this->~ImageData(); + freePixels(); // steal pixels = o.pixels; hdrPixels = o.hdrPixels; + cookedPixels = std::move(o.cookedPixels); hdr = o.hdr; width = o.width; height = o.height; @@ -50,8 +52,11 @@ struct ImageData { ImageData(const ImageData&) = delete; ImageData& operator=(const ImageData&) = delete; + void freePixels(); + unsigned char* pixels{nullptr}; float* hdrPixels{nullptr}; + std::vector cookedPixels; // owns `pixels` for cooked textures bool hdr{false}; int width{0}; diff --git a/destrum/include/destrum/Scene/Scene.h b/destrum/include/destrum/Scene/Scene.h index 556a8b9..11bfb35 100644 --- a/destrum/include/destrum/Scene/Scene.h +++ b/destrum/include/destrum/Scene/Scene.h @@ -12,6 +12,7 @@ #include "destrum/Physics/JoltPhysicsWorld.h" #include "destrum/Physics/PhysicsSceneBridge.h" +class CameraComponent; class GameObject; class SceneSerializer; class PhysicsWorld; @@ -105,6 +106,10 @@ public: Event<> OnSceneLoaded; PhysicsSceneBridge& GetPhysics() { return m_Physics; } + + [[nodiscard]] CameraComponent* GetCurrentCamera() const; + [[nodiscard]] std::vector GetCameras() const; + void SetCurrentCamera(const CameraComponent* camera); private: explicit Scene(const std::string& name); explicit Scene(const std::string& name, std::unique_ptr physicsWorld); @@ -127,6 +132,8 @@ private: bool m_ShowDemoWindow{false}; ObjectId m_SelectedObjectId{InvalidObjectId}; + ObjectId m_CurrentCameraId{InvalidObjectId}; + std::function m_registerBindings; std::function m_unregisterBindings; bool m_bindingsUnloaded{false}; diff --git a/destrum/include/destrum/Serialization/EngineComponentList.h b/destrum/include/destrum/Serialization/EngineComponentList.h index 6c70648..88bd728 100644 --- a/destrum/include/destrum/Serialization/EngineComponentList.h +++ b/destrum/include/destrum/Serialization/EngineComponentList.h @@ -2,6 +2,7 @@ #define DESTRUM_ENGINECOMPONENTLIST_H #include +#include #include #include #include @@ -15,6 +16,7 @@ #define DESTRUM_ENGINE_COMPONENTS(X) \ X(MeshRendererComponent) \ X(LightComponent) \ + X(CameraComponent) \ X(Rotator) \ X(Spinner) \ X(OrbitAndSpin) \ diff --git a/destrum/include/destrum/Util/ImGuiUtils.h b/destrum/include/destrum/Util/ImGuiUtils.h index 4191ecb..cff2ef2 100644 --- a/destrum/include/destrum/Util/ImGuiUtils.h +++ b/destrum/include/destrum/Util/ImGuiUtils.h @@ -1,6 +1,7 @@ #ifndef DESTRUM_IMGUIUTILS_H #define DESTRUM_IMGUIUTILS_H +#include #include #include #include @@ -22,6 +23,7 @@ namespace ImGuiUtils { const std::vector>& objects, const std::vector>& pendingAdditions, ObjectId& selectedObjectId, + const std::function& createGameObject, bool* open = nullptr); } diff --git a/destrum/src/Assets/AssetManager.cpp b/destrum/src/Assets/AssetManager.cpp index c02d59f..2814288 100644 --- a/destrum/src/Assets/AssetManager.cpp +++ b/destrum/src/Assets/AssetManager.cpp @@ -1,7 +1,9 @@ #include +#include #include +#include #include namespace { @@ -11,6 +13,34 @@ namespace { } return std::string{reference}; } + + std::optional FindCookedModel(const std::string& reference) { + if (reference.find("://") == std::string::npos) { + return std::nullopt; + } + + try { + const std::filesystem::path cookedPath = AssetFS::GetInstance().GetCookedPathForFile(reference); + if (cookedPath.extension() == ModelFile::Extension) { + return cookedPath; + } + } catch (const std::exception& exception) { + spdlog::warn("Importing {} instead of using its cooked model: {}", reference, exception.what()); + } + return std::nullopt; + } + + // Cooked texture paths are relative to the model file, imported ones are absolute + void ResolveTexturePaths(ModelDoc::ModelAsset& model, const std::filesystem::path& modelDir) { + for (ModelDoc::MaterialInfo& material : model.materials) { + for (ModelDoc::TextureInfo& texture : material.textures) { + if (texture.embedded || texture.path.empty() || std::filesystem::path{texture.path}.is_absolute()) { + continue; + } + texture.path = (modelDir / texture.path).lexically_normal().generic_string(); + } + } + } } std::filesystem::path AssetManager::Resolve(std::string_view reference) const { @@ -32,9 +62,7 @@ ModelDoc::ModelAsset AssetManager::LoadModel( const ModelDoc::LoadOptions& options) const { const std::string sourceReference = ToReference(reference); - ModelDoc::ModelAsset model = ModelDoc::LoadModel( - Resolve(sourceReference).generic_string(), - options); + ModelDoc::ModelAsset model = LoadCookedOrImport(sourceReference, options); model.sourcePath = sourceReference; model.skeleton.assetReference.path = sourceReference; @@ -44,6 +72,9 @@ ModelDoc::ModelAsset AssetManager::LoadModel( primitive.mesh.assetReference.subresource = primitive.sourceMeshIndex != ModelDoc::InvalidIndex ? "mesh:" + std::to_string(primitive.sourceMeshIndex) : "primitive:" + std::to_string(index); + if (primitive.mesh.skeleton) { + primitive.mesh.skeleton->assetReference.path = sourceReference; + } } for (std::size_t index = 0; index < model.materials.size(); ++index) { auto& material = model.materials[index]; @@ -59,6 +90,24 @@ ModelDoc::ModelAsset AssetManager::LoadModel( return model; } +ModelDoc::ModelAsset AssetManager::LoadCookedOrImport( + const std::string& reference, + const ModelDoc::LoadOptions& options) const +{ + if (const auto cookedPath = FindCookedModel(reference)) { + ModelDoc::ModelAsset model = ModelFile::ReadFromFile(*cookedPath); + if (ModelFile::SupportsLoadOptions(model, options)) { + ModelFile::ApplyLoadOptions(model, options); + ResolveTexturePaths(model, cookedPath->parent_path()); + spdlog::debug("Loaded cooked model {}", reference); + return model; + } + spdlog::debug("Importing {}, its cooked model doesn't match the requested load options", reference); + } + + return ModelDoc::LoadModel(Resolve(reference).generic_string(), options); +} + std::vector AssetManager::LoadAnimationClips( std::string_view reference, const Skeleton& targetSkeleton) const diff --git a/destrum/src/Assets/BinaryStream.h b/destrum/src/Assets/BinaryStream.h new file mode 100644 index 0000000..127c68c --- /dev/null +++ b/destrum/src/Assets/BinaryStream.h @@ -0,0 +1,206 @@ +#ifndef DESTRUM_BINARYSTREAM_H +#define DESTRUM_BINARYSTREAM_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AssetBinary { + static_assert(std::endian::native == std::endian::little, "Cooked assets are stored little endian"); + + using Magic = std::array; + + template + concept RawCopyable = std::is_trivially_copyable_v; + + class BinaryWriter { + public: + template + void Write(const T& value) { + const auto* bytes = reinterpret_cast(&value); + m_Bytes.insert(m_Bytes.end(), bytes, bytes + sizeof(T)); + } + + template + void WriteArray(const std::vector& values) { + WriteCount(values.size()); + const auto* bytes = reinterpret_cast(values.data()); + m_Bytes.insert(m_Bytes.end(), bytes, bytes + values.size() * sizeof(T)); + } + + void WriteCount(std::size_t count) { + Write(static_cast(count)); + } + + void WriteBool(bool value) { + Write(static_cast(value ? 1 : 0)); + } + + void WriteString(std::string_view value) { + WriteCount(value.size()); + m_Bytes.insert(m_Bytes.end(), value.begin(), value.end()); + } + + void WriteStrings(const std::vector& values) { + WriteCount(values.size()); + for (const std::string& value : values) { + WriteString(value); + } + } + + void WriteHeader(const Magic& magic, std::uint32_t version) { + Write(magic); + Write(version); + } + + [[nodiscard]] std::vector TakeBytes() { + return std::move(m_Bytes); + } + + private: + std::vector m_Bytes; + }; + + class BinaryReader { + public: + explicit BinaryReader(std::span bytes) + : m_Bytes(bytes) { + } + + template + [[nodiscard]] T Read() { + T value{}; + std::memcpy(&value, Take(sizeof(T)).data(), sizeof(T)); + return value; + } + + template + [[nodiscard]] std::vector ReadArray() { + const std::size_t count = ReadCount(sizeof(T)); + std::vector values(count); + if (count > 0) { + std::memcpy(values.data(), Take(count * sizeof(T)).data(), count * sizeof(T)); + } + return values; + } + + [[nodiscard]] std::size_t ReadCount(std::size_t minElementSize) { + const auto count = Read(); + if (count > Remaining() / minElementSize) { + throw std::runtime_error("Cooked asset file is corrupt: element count is out of range"); + } + return static_cast(count); + } + + [[nodiscard]] bool ReadBool() { + return Read() != 0; + } + + [[nodiscard]] std::string ReadString() { + const std::size_t length = ReadCount(1); + const auto bytes = Take(length); + return std::string(bytes.begin(), bytes.end()); + } + + [[nodiscard]] std::vector ReadStrings() { + std::vector values(ReadCount(sizeof(std::uint64_t))); + for (std::string& value : values) { + value = ReadString(); + } + return values; + } + + void ReadHeader(const Magic& magic, std::uint32_t version, std::string_view kind) { + if (Read() != magic) { + throw std::runtime_error("Not a cooked " + std::string{kind} + " file"); + } + + const auto fileVersion = Read(); + if (fileVersion != version) { + throw std::runtime_error( + "Cooked " + std::string{kind} + " file has version " + std::to_string(fileVersion) + + ", expected " + std::to_string(version) + " (recook the asset)"); + } + } + + void ExpectEnd(std::string_view kind) const { + if (m_Offset != m_Bytes.size()) { + throw std::runtime_error("Cooked " + std::string{kind} + " file has unexpected data at the end"); + } + } + + private: + [[nodiscard]] std::size_t Remaining() const { + return m_Bytes.size() - m_Offset; + } + + [[nodiscard]] std::span Take(std::size_t size) { + if (size > Remaining()) { + throw std::runtime_error("Cooked asset file is truncated"); + } + const auto bytes = m_Bytes.subspan(m_Offset, size); + m_Offset += size; + return bytes; + } + + std::span m_Bytes; + std::size_t m_Offset{0}; + }; + + inline void WriteAllBytes(const std::filesystem::path& path, const std::vector& bytes) { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) { + throw std::runtime_error("Failed to open file for writing: " + path.string()); + } + file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!file) { + throw std::runtime_error("Failed to write file: " + path.string()); + } + } + + [[nodiscard]] inline std::vector ReadAllBytes(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) { + throw std::runtime_error("Failed to open file: " + path.string()); + } + + std::vector bytes(static_cast(file.tellg())); + file.seekg(0); + file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!file) { + throw std::runtime_error("Failed to read file: " + path.string()); + } + return bytes; + } + + [[nodiscard]] inline std::optional ReadVersion(const std::filesystem::path& path, const Magic& magic) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return std::nullopt; + } + + std::array header{}; + file.read(reinterpret_cast(header.data()), static_cast(header.size())); + if (!file) { + return std::nullopt; + } + + BinaryReader reader{header}; + if (reader.Read() != magic) { + return std::nullopt; + } + return reader.Read(); + } +} + +#endif // DESTRUM_BINARYSTREAM_H diff --git a/destrum/src/Assets/ModelFile.cpp b/destrum/src/Assets/ModelFile.cpp index aaa44ad..0a41271 100644 --- a/destrum/src/Assets/ModelFile.cpp +++ b/destrum/src/Assets/ModelFile.cpp @@ -1,141 +1,18 @@ #include -#include -#include -#include -#include -#include -#include -#include +#include "BinaryStream.h" namespace { - static_assert(std::endian::native == std::endian::little, "Model files are stored little endian"); + using AssetBinary::BinaryReader; + using AssetBinary::BinaryWriter; static_assert(sizeof(CPUMesh::Vertex) == 48); static_assert(sizeof(CPUMesh::SkinningData) == 32); static_assert(sizeof(SkeletalAnimation::Keyframe) == 44); static_assert(sizeof(glm::mat4) == 64); - constexpr std::array Magic{'D', 'M', 'D', 'L'}; - - template - concept RawCopyable = std::is_trivially_copyable_v; - - class BinaryWriter { - public: - template - void Write(const T& value) { - const auto* bytes = reinterpret_cast(&value); - m_Bytes.insert(m_Bytes.end(), bytes, bytes + sizeof(T)); - } - - template - void WriteArray(const std::vector& values) { - WriteCount(values.size()); - const auto* bytes = reinterpret_cast(values.data()); - m_Bytes.insert(m_Bytes.end(), bytes, bytes + values.size() * sizeof(T)); - } - - void WriteCount(std::size_t count) { - Write(static_cast(count)); - } - - void WriteBool(bool value) { - Write(static_cast(value ? 1 : 0)); - } - - void WriteString(std::string_view value) { - WriteCount(value.size()); - m_Bytes.insert(m_Bytes.end(), value.begin(), value.end()); - } - - void WriteStrings(const std::vector& values) { - WriteCount(values.size()); - for (const std::string& value : values) { - WriteString(value); - } - } - - [[nodiscard]] std::vector TakeBytes() { - return std::move(m_Bytes); - } - - private: - std::vector m_Bytes; - }; - - class BinaryReader { - public: - explicit BinaryReader(std::span bytes) - : m_Bytes(bytes) { - } - - template - [[nodiscard]] T Read() { - T value{}; - std::memcpy(&value, Take(sizeof(T)).data(), sizeof(T)); - return value; - } - - template - [[nodiscard]] std::vector ReadArray() { - const std::size_t count = ReadCount(sizeof(T)); - std::vector values(count); - if (count > 0) { - std::memcpy(values.data(), Take(count * sizeof(T)).data(), count * sizeof(T)); - } - return values; - } - - // Every element takes at least minElementSize bytes, which keeps a corrupt count - // from allocating far more than the file could ever contain - [[nodiscard]] std::size_t ReadCount(std::size_t minElementSize) { - const auto count = Read(); - if (count > Remaining() / minElementSize) { - throw std::runtime_error("Model file is corrupt: element count is out of range"); - } - return static_cast(count); - } - - [[nodiscard]] bool ReadBool() { - return Read() != 0; - } - - [[nodiscard]] std::string ReadString() { - const std::size_t length = ReadCount(1); - const auto bytes = Take(length); - return std::string(bytes.begin(), bytes.end()); - } - - [[nodiscard]] std::vector ReadStrings() { - std::vector values(ReadCount(sizeof(std::uint64_t))); - for (std::string& value : values) { - value = ReadString(); - } - return values; - } - - [[nodiscard]] bool AtEnd() const { - return m_Offset == m_Bytes.size(); - } - - private: - [[nodiscard]] std::size_t Remaining() const { - return m_Bytes.size() - m_Offset; - } - - [[nodiscard]] std::span Take(std::size_t size) { - if (size > Remaining()) { - throw std::runtime_error("Model file is truncated"); - } - const auto bytes = m_Bytes.subspan(m_Offset, size); - m_Offset += size; - return bytes; - } - - std::span m_Bytes; - std::size_t m_Offset{0}; - }; + constexpr AssetBinary::Magic Magic{'D', 'M', 'D', 'L'}; + constexpr std::string_view Kind = "model"; void WriteAssetReference(BinaryWriter& writer, const AssetReference& reference) { writer.WriteString(reference.path); @@ -160,7 +37,6 @@ namespace { writer.WriteArray(skeleton.inverseBindMatrices); - // Joint has padding after its id, so it's written field by field writer.WriteCount(skeleton.joints.size()); for (const Joint& joint : skeleton.joints) { writer.Write(joint.id); @@ -350,8 +226,7 @@ namespace { std::vector ModelFile::Write(const ModelDoc::ModelAsset& model) { BinaryWriter writer; - writer.Write(Magic); - writer.Write(Version); + writer.WriteHeader(Magic, Version); writer.WriteString(model.sourcePath); @@ -377,17 +252,7 @@ std::vector ModelFile::Write(const ModelDoc::ModelAsset& model) { ModelDoc::ModelAsset ModelFile::Read(std::span bytes) { BinaryReader reader{bytes}; - - if (reader.Read>() != Magic) { - throw std::runtime_error("Not a cooked model file"); - } - - const auto version = reader.Read(); - if (version != Version) { - throw std::runtime_error( - "Cooked model file has version " + std::to_string(version) + - ", expected " + std::to_string(Version) + " (recook the asset)"); - } + reader.ReadHeader(Magic, Version, Kind); ModelDoc::ModelAsset model; model.sourcePath = reader.ReadString(); @@ -409,36 +274,49 @@ ModelDoc::ModelAsset ModelFile::Read(std::span bytes) { animation = ReadAnimation(reader); } - if (!reader.AtEnd()) { - throw std::runtime_error("Cooked model file has unexpected data at the end"); - } + reader.ExpectEnd(Kind); return model; } void ModelFile::WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path) { - const std::vector bytes = Write(model); - - std::ofstream file(path, std::ios::binary | std::ios::trunc); - if (!file) { - throw std::runtime_error("Failed to open model file for writing: " + path.string()); - } - file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - if (!file) { - throw std::runtime_error("Failed to write model file: " + path.string()); - } + AssetBinary::WriteAllBytes(path, Write(model)); } ModelDoc::ModelAsset ModelFile::ReadFromFile(const std::filesystem::path& path) { - std::ifstream file(path, std::ios::binary | std::ios::ate); - if (!file) { - throw std::runtime_error("Failed to open model file: " + path.string()); + return Read(AssetBinary::ReadAllBytes(path)); +} + +std::optional ModelFile::ReadVersionFromFile(const std::filesystem::path& path) { + return AssetBinary::ReadVersion(path, Magic); +} + +bool ModelFile::SupportsLoadOptions(const ModelDoc::ModelAsset& model, const ModelDoc::LoadOptions& options) { + const bool sameImport = + options.meshImportMode == CookOptions.meshImportMode && + options.flipUVs == CookOptions.flipUVs && + options.generateTangents == CookOptions.generateTangents && + options.generateSmoothNormals == CookOptions.generateSmoothNormals && + options.joinIdenticalVertices == CookOptions.joinIdenticalVertices && + options.limitBoneWeights == CookOptions.limitBoneWeights && + options.bakeStaticNodeTransforms == CookOptions.bakeStaticNodeTransforms; + + // Meshes are built against the skeleton, so skinned models without it import differently + const bool sameMeshes = options.loadSkeleton || !model.IsSkinned(); + + return sameImport && sameMeshes; +} + +void ModelFile::ApplyLoadOptions(ModelDoc::ModelAsset& model, const ModelDoc::LoadOptions& options) { + if (!options.loadMaterials) { + model.materials.clear(); + for (ModelDoc::MeshPrimitive& primitive : model.primitives) { + primitive.materialIndex = ModelDoc::InvalidIndex; + } + } + if (!options.loadSkeleton) { + model.skeleton = Skeleton{}; } - - std::vector bytes(static_cast(file.tellg())); - file.seekg(0); - file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - if (!file) { - throw std::runtime_error("Failed to read model file: " + path.string()); + if (!options.loadAnimations) { + model.animations.clear(); } - return Read(bytes); } diff --git a/destrum/src/Assets/TextureFile.cpp b/destrum/src/Assets/TextureFile.cpp new file mode 100644 index 0000000..c1dc580 --- /dev/null +++ b/destrum/src/Assets/TextureFile.cpp @@ -0,0 +1,90 @@ +#include + +#include +#include + +#include "BinaryStream.h" + +namespace { + using AssetBinary::BinaryReader; + using AssetBinary::BinaryWriter; + + constexpr AssetBinary::Magic Magic{'D', 'T', 'E', 'X'}; + constexpr std::string_view Kind = "texture"; + + std::size_t ExpectedByteSize(const TextureFile::Texture& texture) { + return std::size_t{texture.width} * texture.height * TextureFile::BytesPerPixel(texture.format); + } +} + +std::size_t TextureFile::BytesPerPixel(PixelFormat format) { + switch (format) { + case PixelFormat::RGBA16Float: + return 4 * sizeof(std::uint16_t); + } + throw std::runtime_error("Unknown cooked texture pixel format"); +} + +TextureFile::Texture TextureFile::FromRGBA32Float(std::uint32_t width, std::uint32_t height, std::span rgba) { + const std::size_t pixelCount = std::size_t{width} * height; + if (rgba.size() != pixelCount * 4) { + throw std::invalid_argument("RGBA pixel data doesn't match the texture size"); + } + + Texture texture{ + .width = width, + .height = height, + .format = PixelFormat::RGBA16Float, + }; + texture.pixels.resize(pixelCount * BytesPerPixel(texture.format)); + + for (std::size_t i = 0; i < pixelCount; ++i) { + const glm::vec4 color{rgba[i * 4 + 0], rgba[i * 4 + 1], rgba[i * 4 + 2], rgba[i * 4 + 3]}; + const std::uint64_t packed = glm::packHalf4x16(color); + std::memcpy(&texture.pixels[i * sizeof(packed)], &packed, sizeof(packed)); + } + return texture; +} + +std::vector TextureFile::Write(const Texture& texture) { + if (texture.pixels.size() != ExpectedByteSize(texture)) { + throw std::invalid_argument("Texture pixel data doesn't match its size and format"); + } + + BinaryWriter writer; + writer.WriteHeader(Magic, Version); + writer.Write(texture.width); + writer.Write(texture.height); + writer.Write(static_cast(texture.format)); + writer.WriteArray(texture.pixels); + return writer.TakeBytes(); +} + +TextureFile::Texture TextureFile::Read(std::span bytes) { + BinaryReader reader{bytes}; + reader.ReadHeader(Magic, Version, Kind); + + Texture texture; + texture.width = reader.Read(); + texture.height = reader.Read(); + texture.format = static_cast(reader.Read()); + texture.pixels = reader.ReadArray(); + reader.ExpectEnd(Kind); + + if (texture.pixels.size() != ExpectedByteSize(texture)) { + throw std::runtime_error("Cooked texture file is corrupt: pixel data doesn't match its size"); + } + return texture; +} + +void TextureFile::WriteToFile(const Texture& texture, const std::filesystem::path& path) { + AssetBinary::WriteAllBytes(path, Write(texture)); +} + +TextureFile::Texture TextureFile::ReadFromFile(const std::filesystem::path& path) { + return Read(AssetBinary::ReadAllBytes(path)); +} + +std::optional TextureFile::ReadVersionFromFile(const std::filesystem::path& path) { + return AssetBinary::ReadVersion(path, Magic); +} diff --git a/destrum/src/Components/CameraComponent.cpp b/destrum/src/Components/CameraComponent.cpp new file mode 100644 index 0000000..2c60805 --- /dev/null +++ b/destrum/src/Components/CameraComponent.cpp @@ -0,0 +1,90 @@ +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +CameraComponent::CameraComponent(GameObject& parent) + : Component(parent, "CameraComponent") +{ +} + +Camera& CameraComponent::GetCamera(float aspectRatio) +{ + auto& transform = GetTransform(); + const glm::quat rotation = transform.GetWorldRotation(); + + m_Camera.m_position = transform.GetWorldPosition(); + m_Camera.SetOrientation(rotation * glm::vec3{0.0f, 0.0f, -1.0f}, rotation * glm::vec3{0.0f, 1.0f, 0.0f}); + m_Camera.SetFOVY(m_FieldOfView); + m_Camera.SetZNear(m_ZNear); + m_Camera.SetZFar(m_ZFar); + m_Camera.setAspectRatio(aspectRatio); + return m_Camera; +} + +void CameraComponent::SetFieldOfView(float fovDegrees) +{ + m_FieldOfView = std::clamp(fovDegrees, 1.0f, 179.0f); +} + +void CameraComponent::SetClipPlanes(float zNear, float zFar) +{ + m_ZNear = std::max(zNear, 0.0001f); + m_ZFar = std::max(zFar, m_ZNear + 0.01f); +} + +void CameraComponent::MakeCurrent() +{ + if (Scene* scene = GetGameObject()->GetScene()) { + scene->SetCurrentCamera(this); + } +} + +bool CameraComponent::IsCurrent() const +{ + const Scene* scene = GetGameObject()->GetScene(); + return scene != nullptr && scene->GetCurrentCamera() == this; +} + +nlohmann::json CameraComponent::Serialize() const { + return { + {"fieldOfView", m_FieldOfView}, + {"zNear", m_ZNear}, + {"zFar", m_ZFar} + }; +} + +void CameraComponent::Deserialize(const nlohmann::json& data) { + if (data.contains("fieldOfView")) { + SetFieldOfView(data.at("fieldOfView").get()); + } + SetClipPlanes(data.value("zNear", m_ZNear), data.value("zFar", m_ZFar)); +} + +void CameraComponent::ImGuiInspector() { + bool current = IsCurrent(); + if (ImGui::Checkbox("Current Camera", ¤t) && current) { + MakeCurrent(); + } + + float fieldOfView = m_FieldOfView; + if (ImGui::DragFloat("Field Of View", &fieldOfView, 0.5f, 1.0f, 179.0f)) { + SetFieldOfView(fieldOfView); + } + + float zNear = m_ZNear; + float zFar = m_ZFar; + const bool nearChanged = ImGui::DragFloat("Near", &zNear, 0.01f, 0.0001f, 100.0f); + const bool farChanged = ImGui::DragFloat("Far", &zFar, 1.0f, 0.1f, 100000.0f); + if (nearChanged || farChanged) { + SetClipPlanes(zNear, zFar); + } +} diff --git a/destrum/src/Graphics/Camera.cpp b/destrum/src/Graphics/Camera.cpp index fc2238b..77015e1 100644 --- a/destrum/src/Graphics/Camera.cpp +++ b/destrum/src/Graphics/Camera.cpp @@ -114,6 +114,15 @@ void Camera::SetRotation(const glm::vec2& yawPitchRadians) { SetRotation(yawPitchRadians.x, yawPitchRadians.y); } +void Camera::SetOrientation(const glm::vec3& forward, const glm::vec3& up) { + m_useTarget = false; + m_forward = glm::normalize(forward); + m_right = glm::normalize(glm::cross(m_forward, up)); + m_up = glm::normalize(glm::cross(m_right, m_forward)); + m_yaw = std::atan2(m_forward.z, m_forward.x); + m_pitch = std::asin(glm::clamp(m_forward.y, -1.0f, 1.0f)); +} + void Camera::SetTarget(const glm::vec3&) { } diff --git a/destrum/src/Graphics/ImageLoader.cpp b/destrum/src/Graphics/ImageLoader.cpp index 27a628e..3b03e14 100644 --- a/destrum/src/Graphics/ImageLoader.cpp +++ b/destrum/src/Graphics/ImageLoader.cpp @@ -11,7 +11,14 @@ #include "spdlog/spdlog.h" +#include + ImageData::~ImageData() +{ + freePixels(); +} + +void ImageData::freePixels() { if (shouldSTBFree) { if (pixels) stbi_image_free(pixels); @@ -22,6 +29,12 @@ ImageData::~ImageData() if (hdrPixels) free(hdrPixels); spdlog::debug("Freed TinyEXR image memory"); } + + cookedPixels.clear(); + pixels = nullptr; + hdrPixels = nullptr; + shouldSTBFree = false; + shouldEXRFree = false; } namespace util @@ -33,13 +46,46 @@ namespace util return ext == ".exr"; } + static bool isCookedTextureExt(const std::filesystem::path& p) + { + auto ext = p.extension().string(); + for (auto& c : ext) c = char(::tolower(c)); + return ext == TextureFile::Extension; + } + + static VkFormat toVkFormat(TextureFile::PixelFormat format) + { + switch (format) { + case TextureFile::PixelFormat::RGBA16Float: return VK_FORMAT_R16G16B16A16_SFLOAT; + } + return VK_FORMAT_UNDEFINED; + } + ImageData loadImage(const std::filesystem::path& p, TextureIntent intent) { ImageData data; - // ---------- EXR ---------- + if (isCookedTextureExt(p)) { + try { + TextureFile::Texture texture = TextureFile::ReadFromFile(p); + + data.cookedPixels = std::move(texture.pixels); + data.pixels = data.cookedPixels.data(); + data.width = static_cast(texture.width); + data.height = static_cast(texture.height); + data.channels = 4; + + data.vkFormat = toVkFormat(texture.format); + data.byteSize = data.cookedPixels.size(); + } catch (const std::exception& exception) { + spdlog::error("Failed to read cooked texture '{}': {}", p.string(), exception.what()); + return {}; + } + return data; + } + if (isExrExt(p)) { float* out = nullptr; int w = 0, h = 0; diff --git a/destrum/src/Scene/Scene.cpp b/destrum/src/Scene/Scene.cpp index 79afb2b..898b9d6 100644 --- a/destrum/src/Scene/Scene.cpp +++ b/destrum/src/Scene/Scene.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -164,12 +165,41 @@ void Scene::Render(const RenderContext& ctx) { } +CameraComponent* Scene::GetCurrentCamera() const { + const auto cameras = GetCameras(); + for (CameraComponent* camera : cameras) { + if (camera->GetGameObject()->GetId() == m_CurrentCameraId) { + return camera; + } + } + return cameras.empty() ? nullptr : cameras.front(); +} + +std::vector Scene::GetCameras() const { + std::vector cameras; + for (const auto& object : m_objects) { + if (object == nullptr || object->IsBeingDestroyed() || !object->IsActiveInHierarchy()) { + continue; + } + CameraComponent* camera = object->GetComponent(); + if (camera != nullptr && camera->isEnabled()) { + cameras.push_back(camera); + } + } + return cameras; +} + +void Scene::SetCurrentCamera(const CameraComponent* camera) { + m_CurrentCameraId = camera != nullptr ? camera->GetGameObject()->GetId() : InvalidObjectId; +} + void Scene::RenderImgui(bool* open) { ImGuiUtils::RenderSceneInspector( m_name, m_objects, m_pendingAdditions, m_SelectedObjectId, + [this] { return CreateGameObject("GameObject"); }, open); } diff --git a/destrum/src/Serialization/SceneSerializer.cpp b/destrum/src/Serialization/SceneSerializer.cpp index ad9c239..e883ed1 100644 --- a/destrum/src/Serialization/SceneSerializer.cpp +++ b/destrum/src/Serialization/SceneSerializer.cpp @@ -297,6 +297,9 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { root["version"] = 2; root["name"] = scene.GetName(); root["objects"] = json::array(); + if (scene.m_CurrentCameraId != InvalidObjectId) { + root["currentCamera"] = scene.m_CurrentCameraId; + } for (const auto& objectPtr : scene.GetObjects()) { if (!objectPtr || objectPtr->IsBeingDestroyed()) { @@ -569,6 +572,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, if (root.contains("name")) { scene.m_name = root.at("name").get(); } + scene.m_CurrentCameraId = root.value("currentCamera", InvalidObjectId); if (progressCallback) progressCallback(0.9f); diff --git a/destrum/src/Util/ImGuiUtils.cpp b/destrum/src/Util/ImGuiUtils.cpp index d406304..d7789a6 100644 --- a/destrum/src/Util/ImGuiUtils.cpp +++ b/destrum/src/Util/ImGuiUtils.cpp @@ -124,6 +124,7 @@ namespace ImGuiUtils { const std::vector>& objectsSource, const std::vector>& pendingAdditions, ObjectId& selectedObjectId, + const std::function& createGameObject, bool* open) { RegisterEngineComponents(); @@ -166,6 +167,15 @@ namespace ImGuiUtils { ImGui::Text("Scene: %s", displaySceneName.c_str()); ImGui::SameLine(); ImGui::TextDisabled("%zu GameObjects | %zu roots", objects.size(), roots.size()); + if (createGameObject) { + ImGui::SameLine(); + if (ImGui::Button("+ Add GameObject")) { + if (GameObject* created = createGameObject()) { + objects.push_back(created); + selectedObjectId = created->GetId(); + } + } + } ImGui::Separator(); const float availableWidth = ImGui::GetContentRegionAvail().x; diff --git a/lightkeeper/CMakeLists.txt b/lightkeeper/CMakeLists.txt index e6505f8..94888af 100644 --- a/lightkeeper/CMakeLists.txt +++ b/lightkeeper/CMakeLists.txt @@ -72,10 +72,6 @@ add_custom_target(_internal_clean_game_assets ) add_custom_target(_internal_cook_game_assets - COMMAND TheChef - --input "${ASSETS_SRC_DIR}" - --output "${ASSETS_RUNTIME_DIR}" - --clean COMMAND TheChef --input "${ASSETS_SRC_DIR}" --output "${ASSETS_RUNTIME_DIR}" diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index 684cb9d..54ce369 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -10,6 +10,7 @@ #include "destrum/Graphics/Resources/Cubemap.h" #include "destrum/ObjectModel/GameObject.h" +class CameraComponent; class FirstPersonController; class LightKeeper final : public App { public: @@ -30,6 +31,7 @@ private: void loadScene(); void createEmptyScene(); void submitBoxColliderDebugLines(); + [[nodiscard]] CameraComponent* getSceneCamera() const; Camera camera{glm::vec3(0.f, 0.f, -5.f), glm::vec3(0, 1, 0)}; @@ -51,6 +53,7 @@ private: bool renderQuads{true}; bool renderDebugLines{true}; bool showTestWindow{true}; + bool useFreeCamera{false}; }; #endif //LIGHTKEEPER_H diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index 04a437d..65577e5 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -22,6 +22,7 @@ #include "destrum/Util/ModelDoc.h" #include "destrum/Components/Animator.h" #include "destrum/Components/LightComponent.h" +#include "destrum/Components/CameraComponent.h" #include #include @@ -109,10 +110,7 @@ void LightKeeper::customInit() const auto planeObj = scene.CreateGameObject("GroundPlane"); const auto planeMeshComp = planeObj->AddComponent(); - auto planeModel = ModelDoc::LoadModel( - AssetFS::GetInstance().GetFullPath("game://plane.glb").generic_string(), - staticModelOptions - ); + auto planeModel = AssetManager::GetInstance().LoadModel("game://plane.glb", staticModelOptions); ModelDocUtils::LogModelDocSummary(planeModel, "plane.glb"); const auto& planePrimitive = ModelDocUtils::GetFirstPrimitiveOrThrow(planeModel, "game://plane.glb"); @@ -153,8 +151,8 @@ void LightKeeper::customInit() characterModelOptions.loadSkeleton = true; characterModelOptions.loadAnimations = true; - auto charModel = ModelDoc::LoadModel( - AssetFS::GetInstance().GetFullPath("engine://cotw-capybara-male/source/capybara.fbx").generic_string(), + auto charModel = AssetManager::GetInstance().LoadModel( + "engine://cotw-capybara-male/source/capybara.fbx", characterModelOptions ); ModelDocUtils::LogModelDocSummary(charModel, "capybara.fbx"); @@ -521,7 +519,7 @@ void LightKeeper::customUpdate(float dt) camera.CalculateViewMatrix(); camera.CalculateProjectionMatrix(); } - else + else if (getSceneCamera() == nullptr) { camera.Update(dt); } @@ -563,6 +561,15 @@ void LightKeeper::customUpdate(float dt) } +CameraComponent* LightKeeper::getSceneCamera() const +{ + const auto& activeScenes = SceneManager::GetInstance().GetActiveScenes(); + if (useFreeCamera || activeScenes.empty()) { + return nullptr; + } + return activeScenes.front()->GetCurrentCamera(); +} + void LightKeeper::customWindowsMenu() { ImGui::Separator(); @@ -616,6 +623,27 @@ void LightKeeper::drawDebugMenuBar() ImGui::EndMenu(); } + if (ImGui::BeginMenu("Camera")) { + ImGui::MenuItem("Free Camera", nullptr, &useFreeCamera); + ImGui::Separator(); + + auto& scene = SceneManager::GetInstance().GetCurrentScene(); + const auto cameras = scene.GetCameras(); + if (cameras.empty()) { + ImGui::TextDisabled("No cameras in the scene"); + } + for (CameraComponent* sceneCamera : cameras) { + const GameObject* owner = sceneCamera->GetGameObject(); + ImGui::PushID(owner); + if (ImGui::MenuItem(owner->GetName().c_str(), nullptr, !useFreeCamera && sceneCamera->IsCurrent())) { + scene.SetCurrentCamera(sceneCamera); + useFreeCamera = false; + } + ImGui::PopID(); + } + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); } @@ -751,11 +779,14 @@ void LightKeeper::customDraw() glm::vec2{100.0f, 100.0f}, glm::vec2{200.0f, 200.0f}, 0.0f, glm::vec4{1.0f}, texID); + CameraComponent* sceneCamera = getSceneCamera(); + Camera& viewCamera = sceneCamera != nullptr ? sceneCamera->GetCamera(camera.GetAspectRatio()) : camera; + const RenderContext ctx{ .renderer = renderer, - .camera = camera, + .camera = viewCamera, .sceneData = { - .camera = camera, + .camera = viewCamera, .ambientColor = glm::vec3(0.1f), .ambientIntensity = 0.5f, .fogColor = glm::vec3(0.5f), @@ -769,8 +800,8 @@ void LightKeeper::customDraw() const auto& drawImage = renderer.getDrawImage(); renderer.draw( - cmd, gfxDevice, camera, GameRenderer::SceneData{ - camera, glm::vec3(0.1f), 0.5f, glm::vec3(0.5f), 0.01f + cmd, gfxDevice, viewCamera, GameRenderer::SceneData{ + viewCamera, glm::vec3(0.1f), 0.5f, glm::vec3(0.5f), 0.01f }); gfxDevice.endFrame( diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index 667047f..46d21f3 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -1,14 +1,19 @@ #include #include +#include #include #include #include #include #include +#include + #include +#include #include #include +#include #include #include #include @@ -23,9 +28,11 @@ #include #include #include +#include #include #include #include +#include #include 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(); + scene.CommitPendingAdditions(); + check(scene.GetCurrentCamera() == firstCamera, "the only camera in a scene must be the current camera"); + + GameObject* second = scene.CreateGameObject("second camera"); + auto* secondCamera = second->AddComponent(); + secondCamera->SetFieldOfView(60.0f); + second->GetTransform().SetWorldPosition({1.0f, 2.0f, 3.0f}); + scene.CommitPendingAdditions(); + secondCamera->MakeCurrent(); + check(scene.GetCurrentCamera() == secondCamera && !firstCamera->IsCurrent(), + "the selected camera must be the current camera"); + + const Camera& camera = secondCamera->GetCamera(16.0f / 9.0f); + check(camera.GetPosition() == glm::vec3{1.0f, 2.0f, 3.0f} && + close(camera.GetForward().z, -1.0f) && camera.GetFOVY() == 60.0f, + "a camera component must place its camera at its transform, looking down -Z"); + + const auto path = std::filesystem::temp_directory_path() / "destrum_camera_scene_test.json"; + check(SceneSerializer::Save(scene, path), "scene with cameras must save"); + check(SceneSerializer::Load(scene, path), "scene with cameras must load"); + std::filesystem::remove(path); + + const CameraComponent* loadedCamera = scene.GetCurrentCamera(); + check(loadedCamera != nullptr && loadedCamera->GetGameObject()->GetName() == "second camera" && + loadedCamera->GetFieldOfView() == 60.0f, + "scene round-trip must preserve the current camera"); + + scene.GetCurrentCamera()->GetGameObject()->SetActive(false); + check(scene.GetCurrentCamera() != nullptr && + scene.GetCurrentCamera()->GetGameObject()->GetName() == "first camera", + "an inactive current camera must fall back to another camera"); + + SceneManager::GetInstance().Destroy(); + } + ModelDoc::ModelAsset makeTestModelAsset() { Skeleton skeleton; @@ -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{"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 rgba{ + 0.0f, 0.25f, 0.5f, 1.0f, + 2.0f, 16.0f, 100.0f, 1.0f, + }; + return TextureFile::FromRGBA32Float(2, 1, rgba); + } + + bool textureFileReadThrows(const std::vector& bytes) + { + try { + (void)TextureFile::Read(bytes); + } catch (const std::exception&) { + return true; + } + return false; + } + + void testTextureFileRoundTrip() + { + const auto texture = makeTestTexture(); + check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float), + "cooked textures must store every pixel"); + + std::uint64_t secondPixel = 0; + std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel)); + const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel); + check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f}, + "half float conversion must keep HDR values above 1"); + + const auto bytes = TextureFile::Write(texture); + const auto loaded = TextureFile::Read(bytes); + check(loaded.width == 2 && loaded.height == 1 && + loaded.format == TextureFile::PixelFormat::RGBA16Float && + loaded.pixels == texture.pixels, + "cooked textures must round-trip"); + + auto truncated = bytes; + truncated.resize(bytes.size() - 1); + check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected"); + + auto wrongMagic = bytes; + wrongMagic[0] = 'X'; + check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected"); + + auto wrongSize = bytes; + wrongSize[8] = 3; + check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected"); + } + + void testImageLoaderReadsCookedTextures() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex"; + const auto texture = makeTestTexture(); + TextureFile::WriteToFile(texture, path); + + const auto image = util::loadImage(path, TextureIntent::ColorSrgb); + std::filesystem::remove(path); + + check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT && + image.width == 2 && image.height == 1 && + image.byteSize == texture.pixels.size(), + "cooked textures must load as half float images"); + check(image.pixels != nullptr && + std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0, + "cooked texture pixels must be uploaded as stored"); + } + void testSimpleColorMaterial() { const auto material = Material::SimpleColor( @@ -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; }