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
+1 -1
View File
@@ -9,5 +9,5 @@ lightkeeper/assets_runtime
build/** build/**
# Claude Code
CLAUDE.md CLAUDE.md
docs/**
+1 -1
Submodule TheChef updated: 38230f5092...0273340e3b
+2 -4
View File
@@ -3,6 +3,7 @@ add_subdirectory(third_party)
# CPU side asset types and the cooked model format, shared with TheChef (no Vulkan / SDL dependencies) # CPU side asset types and the cooked model format, shared with TheChef (no Vulkan / SDL dependencies)
add_library(destrum_assets add_library(destrum_assets
"src/Assets/ModelFile.cpp" "src/Assets/ModelFile.cpp"
"src/Assets/TextureFile.cpp"
) )
add_library(destrum::assets ALIAS destrum_assets) add_library(destrum::assets ALIAS destrum_assets)
@@ -35,6 +36,7 @@ set(SRC_FILES
"src/Event.cpp" "src/Event.cpp"
"src/Components/Animator.cpp" "src/Components/Animator.cpp"
"src/Components/CameraComponent.cpp"
"src/Components/LightComponent.cpp" "src/Components/LightComponent.cpp"
"src/Components/MeshRendererComponent.cpp" "src/Components/MeshRendererComponent.cpp"
"src/Components/Rotator.cpp" "src/Components/Rotator.cpp"
@@ -192,10 +194,6 @@ add_custom_target(_internal_clean_engine_assets
) )
add_custom_target(_internal_cook_engine_assets add_custom_target(_internal_cook_engine_assets
COMMAND TheChef
--input "${ASSETS_SRC_DIR}"
--output "${ASSETS_RUNTIME_DIR}"
--clean
COMMAND TheChef COMMAND TheChef
--input "${ASSETS_SRC_DIR}" --input "${ASSETS_SRC_DIR}"
--output "${ASSETS_RUNTIME_DIR}" --output "${ASSETS_RUNTIME_DIR}"
@@ -28,6 +28,10 @@ public:
private: private:
AssetManager() = default; AssetManager() = default;
[[nodiscard]] ModelDoc::ModelAsset LoadCookedOrImport(
const std::string& reference,
const ModelDoc::LoadOptions& options) const;
}; };
#endif // DESTRUM_ASSETMANAGER_H #endif // DESTRUM_ASSETMANAGER_H
@@ -3,6 +3,7 @@
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <optional>
#include <span> #include <span>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
@@ -13,11 +14,18 @@ namespace ModelFile {
inline constexpr std::uint32_t Version = 1; inline constexpr std::uint32_t Version = 1;
inline constexpr std::string_view Extension = ".dmodel"; 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<std::uint8_t> Write(const ModelDoc::ModelAsset& model); [[nodiscard]] std::vector<std::uint8_t> Write(const ModelDoc::ModelAsset& model);
[[nodiscard]] ModelDoc::ModelAsset Read(std::span<const std::uint8_t> bytes); [[nodiscard]] ModelDoc::ModelAsset Read(std::span<const std::uint8_t> bytes);
void WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path); void WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path);
[[nodiscard]] ModelDoc::ModelAsset ReadFromFile(const std::filesystem::path& path); [[nodiscard]] ModelDoc::ModelAsset ReadFromFile(const std::filesystem::path& path);
[[nodiscard]] std::optional<std::uint32_t> ReadVersionFromFile(const std::filesystem::path& path);
} }
#endif // DESTRUM_MODELFILE_H #endif // DESTRUM_MODELFILE_H
@@ -0,0 +1,38 @@
#ifndef DESTRUM_TEXTUREFILE_H
#define DESTRUM_TEXTUREFILE_H
#include <cstdint>
#include <filesystem>
#include <optional>
#include <span>
#include <string_view>
#include <vector>
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<std::uint8_t> pixels;
};
[[nodiscard]] std::size_t BytesPerPixel(PixelFormat format);
[[nodiscard]] Texture FromRGBA32Float(std::uint32_t width, std::uint32_t height, std::span<const float> rgba);
[[nodiscard]] std::vector<std::uint8_t> Write(const Texture& texture);
[[nodiscard]] Texture Read(std::span<const std::uint8_t> bytes);
void WriteToFile(const Texture& texture, const std::filesystem::path& path);
[[nodiscard]] Texture ReadFromFile(const std::filesystem::path& path);
[[nodiscard]] std::optional<std::uint32_t> ReadVersionFromFile(const std::filesystem::path& path);
}
#endif // DESTRUM_TEXTUREFILE_H
@@ -0,0 +1,44 @@
#ifndef CAMERACOMPONENT_H
#define CAMERACOMPONENT_H
#include <destrum/ObjectModel/Component.h>
#include <destrum/Graphics/Camera.h>
#include <glm/glm.hpp>
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
@@ -101,6 +101,7 @@ public:
float GetZFar() const { return m_zFar; } float GetZFar() const { return m_zFar; }
void SetZFar(float zFar) { m_zFar = zFar; } void SetZFar(float zFar) { m_zFar = zFar; }
float GetFOVY() const { return fovAngle; } float GetFOVY() const { return fovAngle; }
void SetFOVY(float fovDegrees) { fovAngle = fovDegrees; }
float GetAspectRatio() const { return m_aspectRatio; } float GetAspectRatio() const { return m_aspectRatio; }
glm::vec3 m_position{2.f, 0, 0}; glm::vec3 m_position{2.f, 0, 0};
@@ -127,6 +128,7 @@ public:
void SetRotation(float yawRadians, float pitchRadians); void SetRotation(float yawRadians, float pitchRadians);
void SetRotation(const glm::vec2& yawPitchRadians); void SetRotation(const glm::vec2& yawPitchRadians);
void SetOrientation(const glm::vec3& forward, const glm::vec3& up);
float GetYaw() const { return m_yaw; } float GetYaw() const { return m_yaw; }
float GetPitch() const { return m_pitch; } float GetPitch() const { return m_pitch; }
@@ -3,6 +3,7 @@
#include <filesystem> #include <filesystem>
#include <cstddef> #include <cstddef>
#include <vector>
#include <vulkan/vulkan.h> #include <vulkan/vulkan.h>
#include "destrum/Graphics/TextureIntent.h" #include "destrum/Graphics/TextureIntent.h"
@@ -19,11 +20,12 @@ struct ImageData {
if (this == &o) return *this; if (this == &o) return *this;
// free current contents first // free current contents first
this->~ImageData(); freePixels();
// steal // steal
pixels = o.pixels; pixels = o.pixels;
hdrPixels = o.hdrPixels; hdrPixels = o.hdrPixels;
cookedPixels = std::move(o.cookedPixels);
hdr = o.hdr; hdr = o.hdr;
width = o.width; width = o.width;
height = o.height; height = o.height;
@@ -50,8 +52,11 @@ struct ImageData {
ImageData(const ImageData&) = delete; ImageData(const ImageData&) = delete;
ImageData& operator=(const ImageData&) = delete; ImageData& operator=(const ImageData&) = delete;
void freePixels();
unsigned char* pixels{nullptr}; unsigned char* pixels{nullptr};
float* hdrPixels{nullptr}; float* hdrPixels{nullptr};
std::vector<unsigned char> cookedPixels; // owns `pixels` for cooked textures
bool hdr{false}; bool hdr{false};
int width{0}; int width{0};
+7
View File
@@ -12,6 +12,7 @@
#include "destrum/Physics/JoltPhysicsWorld.h" #include "destrum/Physics/JoltPhysicsWorld.h"
#include "destrum/Physics/PhysicsSceneBridge.h" #include "destrum/Physics/PhysicsSceneBridge.h"
class CameraComponent;
class GameObject; class GameObject;
class SceneSerializer; class SceneSerializer;
class PhysicsWorld; class PhysicsWorld;
@@ -105,6 +106,10 @@ public:
Event<> OnSceneLoaded; Event<> OnSceneLoaded;
PhysicsSceneBridge& GetPhysics() { return m_Physics; } PhysicsSceneBridge& GetPhysics() { return m_Physics; }
[[nodiscard]] CameraComponent* GetCurrentCamera() const;
[[nodiscard]] std::vector<CameraComponent*> GetCameras() const;
void SetCurrentCamera(const CameraComponent* camera);
private: private:
explicit Scene(const std::string& name); explicit Scene(const std::string& name);
explicit Scene(const std::string& name, std::unique_ptr<PhysicsWorld> physicsWorld); explicit Scene(const std::string& name, std::unique_ptr<PhysicsWorld> physicsWorld);
@@ -127,6 +132,8 @@ private:
bool m_ShowDemoWindow{false}; bool m_ShowDemoWindow{false};
ObjectId m_SelectedObjectId{InvalidObjectId}; ObjectId m_SelectedObjectId{InvalidObjectId};
ObjectId m_CurrentCameraId{InvalidObjectId};
std::function<void()> m_registerBindings; std::function<void()> m_registerBindings;
std::function<void()> m_unregisterBindings; std::function<void()> m_unregisterBindings;
bool m_bindingsUnloaded{false}; bool m_bindingsUnloaded{false};
@@ -2,6 +2,7 @@
#define DESTRUM_ENGINECOMPONENTLIST_H #define DESTRUM_ENGINECOMPONENTLIST_H
#include <destrum/Components/Animator.h> #include <destrum/Components/Animator.h>
#include <destrum/Components/CameraComponent.h>
#include <destrum/Components/LightComponent.h> #include <destrum/Components/LightComponent.h>
#include <destrum/Components/MeshRendererComponent.h> #include <destrum/Components/MeshRendererComponent.h>
#include <destrum/Components/OrbitAndSpin.h> #include <destrum/Components/OrbitAndSpin.h>
@@ -15,6 +16,7 @@
#define DESTRUM_ENGINE_COMPONENTS(X) \ #define DESTRUM_ENGINE_COMPONENTS(X) \
X(MeshRendererComponent) \ X(MeshRendererComponent) \
X(LightComponent) \ X(LightComponent) \
X(CameraComponent) \
X(Rotator) \ X(Rotator) \
X(Spinner) \ X(Spinner) \
X(OrbitAndSpin) \ X(OrbitAndSpin) \
@@ -1,6 +1,7 @@
#ifndef DESTRUM_IMGUIUTILS_H #ifndef DESTRUM_IMGUIUTILS_H
#define DESTRUM_IMGUIUTILS_H #define DESTRUM_IMGUIUTILS_H
#include <functional>
#include <memory> #include <memory>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
@@ -22,6 +23,7 @@ namespace ImGuiUtils {
const std::vector<std::shared_ptr<GameObject>>& objects, const std::vector<std::shared_ptr<GameObject>>& objects,
const std::vector<std::shared_ptr<GameObject>>& pendingAdditions, const std::vector<std::shared_ptr<GameObject>>& pendingAdditions,
ObjectId& selectedObjectId, ObjectId& selectedObjectId,
const std::function<GameObject*()>& createGameObject,
bool* open = nullptr); bool* open = nullptr);
} }
+52 -3
View File
@@ -1,7 +1,9 @@
#include <destrum/Assets/AssetManager.h> #include <destrum/Assets/AssetManager.h>
#include <optional>
#include <stdexcept> #include <stdexcept>
#include <destrum/Assets/ModelFile.h>
#include <destrum/FS/AssetFS.h> #include <destrum/FS/AssetFS.h>
namespace { namespace {
@@ -11,6 +13,34 @@ namespace {
} }
return std::string{reference}; return std::string{reference};
} }
std::optional<std::filesystem::path> 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 { std::filesystem::path AssetManager::Resolve(std::string_view reference) const {
@@ -32,9 +62,7 @@ ModelDoc::ModelAsset AssetManager::LoadModel(
const ModelDoc::LoadOptions& options) const const ModelDoc::LoadOptions& options) const
{ {
const std::string sourceReference = ToReference(reference); const std::string sourceReference = ToReference(reference);
ModelDoc::ModelAsset model = ModelDoc::LoadModel( ModelDoc::ModelAsset model = LoadCookedOrImport(sourceReference, options);
Resolve(sourceReference).generic_string(),
options);
model.sourcePath = sourceReference; model.sourcePath = sourceReference;
model.skeleton.assetReference.path = sourceReference; model.skeleton.assetReference.path = sourceReference;
@@ -44,6 +72,9 @@ ModelDoc::ModelAsset AssetManager::LoadModel(
primitive.mesh.assetReference.subresource = primitive.sourceMeshIndex != ModelDoc::InvalidIndex primitive.mesh.assetReference.subresource = primitive.sourceMeshIndex != ModelDoc::InvalidIndex
? "mesh:" + std::to_string(primitive.sourceMeshIndex) ? "mesh:" + std::to_string(primitive.sourceMeshIndex)
: "primitive:" + std::to_string(index); : "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) { for (std::size_t index = 0; index < model.materials.size(); ++index) {
auto& material = model.materials[index]; auto& material = model.materials[index];
@@ -59,6 +90,24 @@ ModelDoc::ModelAsset AssetManager::LoadModel(
return model; 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<SkeletalAnimation> AssetManager::LoadAnimationClips( std::vector<SkeletalAnimation> AssetManager::LoadAnimationClips(
std::string_view reference, std::string_view reference,
const Skeleton& targetSkeleton) const const Skeleton& targetSkeleton) const
+206
View File
@@ -0,0 +1,206 @@
#ifndef DESTRUM_BINARYSTREAM_H
#define DESTRUM_BINARYSTREAM_H
#include <array>
#include <bit>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <optional>
#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
namespace AssetBinary {
static_assert(std::endian::native == std::endian::little, "Cooked assets are stored little endian");
using Magic = std::array<char, 4>;
template <typename T>
concept RawCopyable = std::is_trivially_copyable_v<T>;
class BinaryWriter {
public:
template <RawCopyable T>
void Write(const T& value) {
const auto* bytes = reinterpret_cast<const std::uint8_t*>(&value);
m_Bytes.insert(m_Bytes.end(), bytes, bytes + sizeof(T));
}
template <RawCopyable T>
void WriteArray(const std::vector<T>& values) {
WriteCount(values.size());
const auto* bytes = reinterpret_cast<const std::uint8_t*>(values.data());
m_Bytes.insert(m_Bytes.end(), bytes, bytes + values.size() * sizeof(T));
}
void WriteCount(std::size_t count) {
Write(static_cast<std::uint64_t>(count));
}
void WriteBool(bool value) {
Write(static_cast<std::uint8_t>(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<std::string>& 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<std::uint8_t> TakeBytes() {
return std::move(m_Bytes);
}
private:
std::vector<std::uint8_t> m_Bytes;
};
class BinaryReader {
public:
explicit BinaryReader(std::span<const std::uint8_t> bytes)
: m_Bytes(bytes) {
}
template <RawCopyable T>
[[nodiscard]] T Read() {
T value{};
std::memcpy(&value, Take(sizeof(T)).data(), sizeof(T));
return value;
}
template <RawCopyable T>
[[nodiscard]] std::vector<T> ReadArray() {
const std::size_t count = ReadCount(sizeof(T));
std::vector<T> 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<std::uint64_t>();
if (count > Remaining() / minElementSize) {
throw std::runtime_error("Cooked asset file is corrupt: element count is out of range");
}
return static_cast<std::size_t>(count);
}
[[nodiscard]] bool ReadBool() {
return Read<std::uint8_t>() != 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<std::string> ReadStrings() {
std::vector<std::string> 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>() != magic) {
throw std::runtime_error("Not a cooked " + std::string{kind} + " file");
}
const auto fileVersion = Read<std::uint32_t>();
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<const std::uint8_t> 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<const std::uint8_t> m_Bytes;
std::size_t m_Offset{0};
};
inline void WriteAllBytes(const std::filesystem::path& path, const std::vector<std::uint8_t>& 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<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!file) {
throw std::runtime_error("Failed to write file: " + path.string());
}
}
[[nodiscard]] inline std::vector<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(file.tellg()));
file.seekg(0);
file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!file) {
throw std::runtime_error("Failed to read file: " + path.string());
}
return bytes;
}
[[nodiscard]] inline std::optional<std::uint32_t> ReadVersion(const std::filesystem::path& path, const Magic& magic) {
std::ifstream file(path, std::ios::binary);
if (!file) {
return std::nullopt;
}
std::array<std::uint8_t, sizeof(Magic) + sizeof(std::uint32_t)> header{};
file.read(reinterpret_cast<char*>(header.data()), static_cast<std::streamsize>(header.size()));
if (!file) {
return std::nullopt;
}
BinaryReader reader{header};
if (reader.Read<Magic>() != magic) {
return std::nullopt;
}
return reader.Read<std::uint32_t>();
}
}
#endif // DESTRUM_BINARYSTREAM_H
+43 -165
View File
@@ -1,141 +1,18 @@
#include <destrum/Assets/ModelFile.h> #include <destrum/Assets/ModelFile.h>
#include <array> #include "BinaryStream.h"
#include <bit>
#include <cstring>
#include <fstream>
#include <stdexcept>
#include <string>
#include <type_traits>
namespace { 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::Vertex) == 48);
static_assert(sizeof(CPUMesh::SkinningData) == 32); static_assert(sizeof(CPUMesh::SkinningData) == 32);
static_assert(sizeof(SkeletalAnimation::Keyframe) == 44); static_assert(sizeof(SkeletalAnimation::Keyframe) == 44);
static_assert(sizeof(glm::mat4) == 64); static_assert(sizeof(glm::mat4) == 64);
constexpr std::array<char, 4> Magic{'D', 'M', 'D', 'L'}; constexpr AssetBinary::Magic Magic{'D', 'M', 'D', 'L'};
constexpr std::string_view Kind = "model";
template <typename T>
concept RawCopyable = std::is_trivially_copyable_v<T>;
class BinaryWriter {
public:
template <RawCopyable T>
void Write(const T& value) {
const auto* bytes = reinterpret_cast<const std::uint8_t*>(&value);
m_Bytes.insert(m_Bytes.end(), bytes, bytes + sizeof(T));
}
template <RawCopyable T>
void WriteArray(const std::vector<T>& values) {
WriteCount(values.size());
const auto* bytes = reinterpret_cast<const std::uint8_t*>(values.data());
m_Bytes.insert(m_Bytes.end(), bytes, bytes + values.size() * sizeof(T));
}
void WriteCount(std::size_t count) {
Write(static_cast<std::uint64_t>(count));
}
void WriteBool(bool value) {
Write(static_cast<std::uint8_t>(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<std::string>& values) {
WriteCount(values.size());
for (const std::string& value : values) {
WriteString(value);
}
}
[[nodiscard]] std::vector<std::uint8_t> TakeBytes() {
return std::move(m_Bytes);
}
private:
std::vector<std::uint8_t> m_Bytes;
};
class BinaryReader {
public:
explicit BinaryReader(std::span<const std::uint8_t> bytes)
: m_Bytes(bytes) {
}
template <RawCopyable T>
[[nodiscard]] T Read() {
T value{};
std::memcpy(&value, Take(sizeof(T)).data(), sizeof(T));
return value;
}
template <RawCopyable T>
[[nodiscard]] std::vector<T> ReadArray() {
const std::size_t count = ReadCount(sizeof(T));
std::vector<T> 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<std::uint64_t>();
if (count > Remaining() / minElementSize) {
throw std::runtime_error("Model file is corrupt: element count is out of range");
}
return static_cast<std::size_t>(count);
}
[[nodiscard]] bool ReadBool() {
return Read<std::uint8_t>() != 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<std::string> ReadStrings() {
std::vector<std::string> 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<const std::uint8_t> 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<const std::uint8_t> m_Bytes;
std::size_t m_Offset{0};
};
void WriteAssetReference(BinaryWriter& writer, const AssetReference& reference) { void WriteAssetReference(BinaryWriter& writer, const AssetReference& reference) {
writer.WriteString(reference.path); writer.WriteString(reference.path);
@@ -160,7 +37,6 @@ namespace {
writer.WriteArray(skeleton.inverseBindMatrices); writer.WriteArray(skeleton.inverseBindMatrices);
// Joint has padding after its id, so it's written field by field
writer.WriteCount(skeleton.joints.size()); writer.WriteCount(skeleton.joints.size());
for (const Joint& joint : skeleton.joints) { for (const Joint& joint : skeleton.joints) {
writer.Write(joint.id); writer.Write(joint.id);
@@ -350,8 +226,7 @@ namespace {
std::vector<std::uint8_t> ModelFile::Write(const ModelDoc::ModelAsset& model) { std::vector<std::uint8_t> ModelFile::Write(const ModelDoc::ModelAsset& model) {
BinaryWriter writer; BinaryWriter writer;
writer.Write(Magic); writer.WriteHeader(Magic, Version);
writer.Write(Version);
writer.WriteString(model.sourcePath); writer.WriteString(model.sourcePath);
@@ -377,17 +252,7 @@ std::vector<std::uint8_t> ModelFile::Write(const ModelDoc::ModelAsset& model) {
ModelDoc::ModelAsset ModelFile::Read(std::span<const std::uint8_t> bytes) { ModelDoc::ModelAsset ModelFile::Read(std::span<const std::uint8_t> bytes) {
BinaryReader reader{bytes}; BinaryReader reader{bytes};
reader.ReadHeader(Magic, Version, Kind);
if (reader.Read<std::array<char, 4>>() != Magic) {
throw std::runtime_error("Not a cooked model file");
}
const auto version = reader.Read<std::uint32_t>();
if (version != Version) {
throw std::runtime_error(
"Cooked model file has version " + std::to_string(version) +
", expected " + std::to_string(Version) + " (recook the asset)");
}
ModelDoc::ModelAsset model; ModelDoc::ModelAsset model;
model.sourcePath = reader.ReadString(); model.sourcePath = reader.ReadString();
@@ -409,36 +274,49 @@ ModelDoc::ModelAsset ModelFile::Read(std::span<const std::uint8_t> bytes) {
animation = ReadAnimation(reader); animation = ReadAnimation(reader);
} }
if (!reader.AtEnd()) { reader.ExpectEnd(Kind);
throw std::runtime_error("Cooked model file has unexpected data at the end");
}
return model; return model;
} }
void ModelFile::WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path) { void ModelFile::WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path) {
const std::vector<std::uint8_t> bytes = Write(model); AssetBinary::WriteAllBytes(path, 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<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!file) {
throw std::runtime_error("Failed to write model file: " + path.string());
}
} }
ModelDoc::ModelAsset ModelFile::ReadFromFile(const std::filesystem::path& path) { ModelDoc::ModelAsset ModelFile::ReadFromFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate); return Read(AssetBinary::ReadAllBytes(path));
if (!file) { }
throw std::runtime_error("Failed to open model file: " + path.string());
std::optional<std::uint32_t> 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{};
} }
if (!options.loadAnimations) {
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(file.tellg())); model.animations.clear();
file.seekg(0);
file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!file) {
throw std::runtime_error("Failed to read model file: " + path.string());
} }
return Read(bytes);
} }
+90
View File
@@ -0,0 +1,90 @@
#include <destrum/Assets/TextureFile.h>
#include <glm/gtc/packing.hpp>
#include <glm/vec4.hpp>
#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<const float> 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<std::uint8_t> 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<std::uint32_t>(texture.format));
writer.WriteArray(texture.pixels);
return writer.TakeBytes();
}
TextureFile::Texture TextureFile::Read(std::span<const std::uint8_t> bytes) {
BinaryReader reader{bytes};
reader.ReadHeader(Magic, Version, Kind);
Texture texture;
texture.width = reader.Read<std::uint32_t>();
texture.height = reader.Read<std::uint32_t>();
texture.format = static_cast<PixelFormat>(reader.Read<std::uint32_t>());
texture.pixels = reader.ReadArray<std::uint8_t>();
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<std::uint32_t> TextureFile::ReadVersionFromFile(const std::filesystem::path& path) {
return AssetBinary::ReadVersion(path, Magic);
}
@@ -0,0 +1,90 @@
#include <destrum/Components/CameraComponent.h>
#include <destrum/ObjectModel/GameObject.h>
#include <destrum/ObjectModel/Transform.h>
#include <destrum/Scene/Scene.h>
#include <glm/gtc/quaternion.hpp>
#include <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <imgui.h>
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<float>());
}
SetClipPlanes(data.value("zNear", m_ZNear), data.value("zFar", m_ZFar));
}
void CameraComponent::ImGuiInspector() {
bool current = IsCurrent();
if (ImGui::Checkbox("Current Camera", &current) && 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);
}
}
+9
View File
@@ -114,6 +114,15 @@ void Camera::SetRotation(const glm::vec2& yawPitchRadians) {
SetRotation(yawPitchRadians.x, yawPitchRadians.y); 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&) { void Camera::SetTarget(const glm::vec3&) {
} }
+47 -1
View File
@@ -11,7 +11,14 @@
#include "spdlog/spdlog.h" #include "spdlog/spdlog.h"
#include <destrum/Assets/TextureFile.h>
ImageData::~ImageData() ImageData::~ImageData()
{
freePixels();
}
void ImageData::freePixels()
{ {
if (shouldSTBFree) { if (shouldSTBFree) {
if (pixels) stbi_image_free(pixels); if (pixels) stbi_image_free(pixels);
@@ -22,6 +29,12 @@ ImageData::~ImageData()
if (hdrPixels) free(hdrPixels); if (hdrPixels) free(hdrPixels);
spdlog::debug("Freed TinyEXR image memory"); spdlog::debug("Freed TinyEXR image memory");
} }
cookedPixels.clear();
pixels = nullptr;
hdrPixels = nullptr;
shouldSTBFree = false;
shouldEXRFree = false;
} }
namespace util namespace util
@@ -33,13 +46,46 @@ namespace util
return ext == ".exr"; 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 loadImage(const std::filesystem::path& p, TextureIntent intent)
{ {
ImageData data; 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<int>(texture.width);
data.height = static_cast<int>(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)) { if (isExrExt(p)) {
float* out = nullptr; float* out = nullptr;
int w = 0, h = 0; int w = 0, h = 0;
+30
View File
@@ -1,4 +1,5 @@
#include <destrum/Scene/Scene.h> #include <destrum/Scene/Scene.h>
#include <destrum/Components/CameraComponent.h>
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
#include <destrum/Util/ImGuiUtils.h> #include <destrum/Util/ImGuiUtils.h>
@@ -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<CameraComponent*> Scene::GetCameras() const {
std::vector<CameraComponent*> cameras;
for (const auto& object : m_objects) {
if (object == nullptr || object->IsBeingDestroyed() || !object->IsActiveInHierarchy()) {
continue;
}
CameraComponent* camera = object->GetComponent<CameraComponent>();
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) { void Scene::RenderImgui(bool* open) {
ImGuiUtils::RenderSceneInspector( ImGuiUtils::RenderSceneInspector(
m_name, m_name,
m_objects, m_objects,
m_pendingAdditions, m_pendingAdditions,
m_SelectedObjectId, m_SelectedObjectId,
[this] { return CreateGameObject("GameObject"); },
open); open);
} }
@@ -297,6 +297,9 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) {
root["version"] = 2; root["version"] = 2;
root["name"] = scene.GetName(); root["name"] = scene.GetName();
root["objects"] = json::array(); root["objects"] = json::array();
if (scene.m_CurrentCameraId != InvalidObjectId) {
root["currentCamera"] = scene.m_CurrentCameraId;
}
for (const auto& objectPtr : scene.GetObjects()) { for (const auto& objectPtr : scene.GetObjects()) {
if (!objectPtr || objectPtr->IsBeingDestroyed()) { if (!objectPtr || objectPtr->IsBeingDestroyed()) {
@@ -569,6 +572,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result,
if (root.contains("name")) { if (root.contains("name")) {
scene.m_name = root.at("name").get<std::string>(); scene.m_name = root.at("name").get<std::string>();
} }
scene.m_CurrentCameraId = root.value("currentCamera", InvalidObjectId);
if (progressCallback) progressCallback(0.9f); if (progressCallback) progressCallback(0.9f);
+10
View File
@@ -124,6 +124,7 @@ namespace ImGuiUtils {
const std::vector<std::shared_ptr<GameObject>>& objectsSource, const std::vector<std::shared_ptr<GameObject>>& objectsSource,
const std::vector<std::shared_ptr<GameObject>>& pendingAdditions, const std::vector<std::shared_ptr<GameObject>>& pendingAdditions,
ObjectId& selectedObjectId, ObjectId& selectedObjectId,
const std::function<GameObject*()>& createGameObject,
bool* open) { bool* open) {
RegisterEngineComponents(); RegisterEngineComponents();
@@ -166,6 +167,15 @@ namespace ImGuiUtils {
ImGui::Text("Scene: %s", displaySceneName.c_str()); ImGui::Text("Scene: %s", displaySceneName.c_str());
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextDisabled("%zu GameObjects | %zu roots", objects.size(), roots.size()); 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(); ImGui::Separator();
const float availableWidth = ImGui::GetContentRegionAvail().x; const float availableWidth = ImGui::GetContentRegionAvail().x;
-4
View File
@@ -72,10 +72,6 @@ add_custom_target(_internal_clean_game_assets
) )
add_custom_target(_internal_cook_game_assets add_custom_target(_internal_cook_game_assets
COMMAND TheChef
--input "${ASSETS_SRC_DIR}"
--output "${ASSETS_RUNTIME_DIR}"
--clean
COMMAND TheChef COMMAND TheChef
--input "${ASSETS_SRC_DIR}" --input "${ASSETS_SRC_DIR}"
--output "${ASSETS_RUNTIME_DIR}" --output "${ASSETS_RUNTIME_DIR}"
+3
View File
@@ -10,6 +10,7 @@
#include "destrum/Graphics/Resources/Cubemap.h" #include "destrum/Graphics/Resources/Cubemap.h"
#include "destrum/ObjectModel/GameObject.h" #include "destrum/ObjectModel/GameObject.h"
class CameraComponent;
class FirstPersonController; class FirstPersonController;
class LightKeeper final : public App { class LightKeeper final : public App {
public: public:
@@ -30,6 +31,7 @@ private:
void loadScene(); void loadScene();
void createEmptyScene(); void createEmptyScene();
void submitBoxColliderDebugLines(); void submitBoxColliderDebugLines();
[[nodiscard]] CameraComponent* getSceneCamera() const;
Camera camera{glm::vec3(0.f, 0.f, -5.f), glm::vec3(0, 1, 0)}; Camera camera{glm::vec3(0.f, 0.f, -5.f), glm::vec3(0, 1, 0)};
@@ -51,6 +53,7 @@ private:
bool renderQuads{true}; bool renderQuads{true};
bool renderDebugLines{true}; bool renderDebugLines{true};
bool showTestWindow{true}; bool showTestWindow{true};
bool useFreeCamera{false};
}; };
#endif //LIGHTKEEPER_H #endif //LIGHTKEEPER_H
+42 -11
View File
@@ -22,6 +22,7 @@
#include "destrum/Util/ModelDoc.h" #include "destrum/Util/ModelDoc.h"
#include "destrum/Components/Animator.h" #include "destrum/Components/Animator.h"
#include "destrum/Components/LightComponent.h" #include "destrum/Components/LightComponent.h"
#include "destrum/Components/CameraComponent.h"
#include <components/FirstPersonController.h> #include <components/FirstPersonController.h>
#include <components/TriggerLoggerComponent.h> #include <components/TriggerLoggerComponent.h>
@@ -109,10 +110,7 @@ void LightKeeper::customInit()
const auto planeObj = scene.CreateGameObject("GroundPlane"); const auto planeObj = scene.CreateGameObject("GroundPlane");
const auto planeMeshComp = planeObj->AddComponent<MeshRendererComponent>(); const auto planeMeshComp = planeObj->AddComponent<MeshRendererComponent>();
auto planeModel = ModelDoc::LoadModel( auto planeModel = AssetManager::GetInstance().LoadModel("game://plane.glb", staticModelOptions);
AssetFS::GetInstance().GetFullPath("game://plane.glb").generic_string(),
staticModelOptions
);
ModelDocUtils::LogModelDocSummary(planeModel, "plane.glb"); ModelDocUtils::LogModelDocSummary(planeModel, "plane.glb");
const auto& planePrimitive = ModelDocUtils::GetFirstPrimitiveOrThrow(planeModel, "game://plane.glb"); const auto& planePrimitive = ModelDocUtils::GetFirstPrimitiveOrThrow(planeModel, "game://plane.glb");
@@ -153,8 +151,8 @@ void LightKeeper::customInit()
characterModelOptions.loadSkeleton = true; characterModelOptions.loadSkeleton = true;
characterModelOptions.loadAnimations = true; characterModelOptions.loadAnimations = true;
auto charModel = ModelDoc::LoadModel( auto charModel = AssetManager::GetInstance().LoadModel(
AssetFS::GetInstance().GetFullPath("engine://cotw-capybara-male/source/capybara.fbx").generic_string(), "engine://cotw-capybara-male/source/capybara.fbx",
characterModelOptions characterModelOptions
); );
ModelDocUtils::LogModelDocSummary(charModel, "capybara.fbx"); ModelDocUtils::LogModelDocSummary(charModel, "capybara.fbx");
@@ -521,7 +519,7 @@ void LightKeeper::customUpdate(float dt)
camera.CalculateViewMatrix(); camera.CalculateViewMatrix();
camera.CalculateProjectionMatrix(); camera.CalculateProjectionMatrix();
} }
else else if (getSceneCamera() == nullptr)
{ {
camera.Update(dt); 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() void LightKeeper::customWindowsMenu()
{ {
ImGui::Separator(); ImGui::Separator();
@@ -616,6 +623,27 @@ void LightKeeper::drawDebugMenuBar()
ImGui::EndMenu(); 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(); ImGui::EndMainMenuBar();
} }
@@ -751,11 +779,14 @@ void LightKeeper::customDraw()
glm::vec2{100.0f, 100.0f}, glm::vec2{200.0f, 200.0f}, 0.0f, glm::vec2{100.0f, 100.0f}, glm::vec2{200.0f, 200.0f}, 0.0f,
glm::vec4{1.0f}, texID); glm::vec4{1.0f}, texID);
CameraComponent* sceneCamera = getSceneCamera();
Camera& viewCamera = sceneCamera != nullptr ? sceneCamera->GetCamera(camera.GetAspectRatio()) : camera;
const RenderContext ctx{ const RenderContext ctx{
.renderer = renderer, .renderer = renderer,
.camera = camera, .camera = viewCamera,
.sceneData = { .sceneData = {
.camera = camera, .camera = viewCamera,
.ambientColor = glm::vec3(0.1f), .ambientColor = glm::vec3(0.1f),
.ambientIntensity = 0.5f, .ambientIntensity = 0.5f,
.fogColor = glm::vec3(0.5f), .fogColor = glm::vec3(0.5f),
@@ -769,8 +800,8 @@ void LightKeeper::customDraw()
const auto& drawImage = renderer.getDrawImage(); const auto& drawImage = renderer.getDrawImage();
renderer.draw( renderer.draw(
cmd, gfxDevice, camera, GameRenderer::SceneData{ cmd, gfxDevice, viewCamera, GameRenderer::SceneData{
camera, glm::vec3(0.1f), 0.5f, glm::vec3(0.5f), 0.01f viewCamera, glm::vec3(0.1f), 0.5f, glm::vec3(0.5f), 0.01f
}); });
gfxDevice.endFrame( gfxDevice.endFrame(
+187 -3
View File
@@ -1,14 +1,19 @@
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <string> #include <string>
#include <glm/gtc/packing.hpp>
#include <destrum/Event.h> #include <destrum/Event.h>
#include <destrum/Assets/AssetManager.h>
#include <destrum/Assets/AssetReference.h> #include <destrum/Assets/AssetReference.h>
#include <destrum/Assets/ModelFile.h> #include <destrum/Assets/ModelFile.h>
#include <destrum/Assets/TextureFile.h>
#include <destrum/FS/AssetFS.h> #include <destrum/FS/AssetFS.h>
#include <destrum/ObjectModel/Component.h> #include <destrum/ObjectModel/Component.h>
#include <destrum/ObjectModel/GameObject.h> #include <destrum/ObjectModel/GameObject.h>
@@ -23,9 +28,11 @@
#include <destrum/Components/Physics/BoxCollider.h> #include <destrum/Components/Physics/BoxCollider.h>
#include <destrum/Components/Physics/Collider.h> #include <destrum/Components/Physics/Collider.h>
#include <destrum/Components/Animator.h> #include <destrum/Components/Animator.h>
#include <destrum/Components/CameraComponent.h>
#include <destrum/Components/LightComponent.h> #include <destrum/Components/LightComponent.h>
#include <destrum/Components/Physics/Rigidbody.h> #include <destrum/Components/Physics/Rigidbody.h>
#include <destrum/Graphics/Material.h> #include <destrum/Graphics/Material.h>
#include <destrum/Graphics/imageLoader.h>
#include <destrum/Graphics/Managers/LineRenderingManager.h> #include <destrum/Graphics/Managers/LineRenderingManager.h>
namespace { namespace {
@@ -486,6 +493,48 @@ namespace {
"LightComponent range must stay positive"); "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() ModelDoc::ModelAsset makeTestModelAsset()
{ {
Skeleton skeleton; Skeleton skeleton;
@@ -535,11 +584,11 @@ namespace {
material.opacity = 0.5f; material.opacity = 0.5f;
material.textures = {{ material.textures = {{
.semantic = ModelDoc::TextureSemantic::BaseColor, .semantic = ModelDoc::TextureSemantic::BaseColor,
.rawPath = "*0", .rawPath = "skin.png",
.path = "textures/skin.png", .path = "textures/skin.png",
.assimpType = aiTextureType_DIFFUSE, .assimpType = aiTextureType_DIFFUSE,
.slot = 1, .slot = 1,
.embedded = true, .embedded = false,
}}; }};
SkeletalAnimation animation; SkeletalAnimation animation;
@@ -610,7 +659,8 @@ namespace {
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor && check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
texture.path == "textures/skin.png" && texture.path == "textures/skin.png" &&
texture.assimpType == aiTextureType_DIFFUSE && texture.assimpType == aiTextureType_DIFFUSE &&
texture.embedded, texture.slot == 1 &&
!texture.embedded,
"cooked materials must keep their textures"); "cooked materials must keep their textures");
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} && check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
@@ -669,6 +719,136 @@ namespace {
"a cooked imported model must match the import exactly"); "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() void testSimpleColorMaterial()
{ {
const auto material = Material::SimpleColor( const auto material = Material::SimpleColor(
@@ -787,9 +967,13 @@ int main()
testModelFileRoundTrip(); testModelFileRoundTrip();
testModelFileRejectsBadData(); testModelFileRejectsBadData();
testModelFileFromImportedModel(); testModelFileFromImportedModel();
testAssetManagerLoadsCookedModels();
testTextureFileRoundTrip();
testImageLoaderReadsCookedTextures();
testSimpleColorMaterial(); testSimpleColorMaterial();
testAssetReferenceCacheKeys(); testAssetReferenceCacheKeys();
testTriggerZone(); testTriggerZone();
testSceneCurrentCamera();
std::cout << "destrum tests passed\n"; std::cout << "destrum tests passed\n";
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }