feat: add basic format for models

This commit is contained in:
2026-10-11 11:07:43 +02:00
parent 170af2a4e9
commit fa98bf08e8
9 changed files with 739 additions and 42 deletions
+1
View File
@@ -35,6 +35,7 @@ if (TARGET _internal_validate_engine_shaders)
add_dependencies(ValidateShaders _internal_validate_engine_shaders) add_dependencies(ValidateShaders _internal_validate_engine_shaders)
endif() endif()
destrum_enable_warnings(destrum_assets)
destrum_enable_warnings(destrum) destrum_enable_warnings(destrum)
destrum_enable_warnings(lightkeeper) destrum_enable_warnings(lightkeeper)
+33 -10
View File
@@ -1,5 +1,35 @@
add_subdirectory(third_party) 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"
)
add_library(destrum::assets ALIAS destrum_assets)
set_target_properties(destrum_assets PROPERTIES
CXX_STANDARD 20
CXX_EXTENSIONS OFF
)
target_include_directories(destrum_assets PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include")
target_link_libraries(destrum_assets
PUBLIC
glm::glm
assimp
spdlog::spdlog
)
target_compile_definitions(destrum_assets
PUBLIC
GLM_FORCE_CTOR_INIT
GLM_FORCE_XYZW_ONLY
GLM_FORCE_EXPLICIT_CTOR
GLM_FORCE_DEPTH_ZERO_TO_ONE
GLM_ENABLE_EXPERIMENTAL
)
set(SRC_FILES set(SRC_FILES
"src/App.cpp" "src/App.cpp"
"src/Event.cpp" "src/Event.cpp"
@@ -100,6 +130,8 @@ target_include_directories(destrum PRIVATE
target_link_libraries(destrum target_link_libraries(destrum
PUBLIC PUBLIC
destrum_assets
volk::volk_headers volk::volk_headers
vk-bootstrap::vk-bootstrap vk-bootstrap::vk-bootstrap
GPUOpen::VulkanMemoryAllocator GPUOpen::VulkanMemoryAllocator
@@ -137,15 +169,6 @@ else ()
target_link_libraries(destrum PUBLIC SDL2::SDL2-static) target_link_libraries(destrum PUBLIC SDL2::SDL2-static)
endif () endif ()
target_compile_definitions(destrum
PUBLIC
GLM_FORCE_CTOR_INIT
GLM_FORCE_XYZW_ONLY
GLM_FORCE_EXPLICIT_CTOR
GLM_FORCE_DEPTH_ZERO_TO_ONE
GLM_ENABLE_EXPERIMENTAL
)
set(ASSETS_SRC_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_src") set(ASSETS_SRC_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_src")
set(ASSETS_RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_runtime") set(ASSETS_RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_runtime")
#set(OUTPUT_ENGINE_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets/engine") #set(OUTPUT_ENGINE_ASSETS_DIR "${CMAKE_BINARY_DIR}/assets/engine")
@@ -229,7 +252,7 @@ function(destrum_cook_engine_assets GAME_TARGET GAME_OUTPUT_DIR)
install(DIRECTORY "${_engine_runtime}/" DESTINATION "bin/assets/engine") install(DIRECTORY "${_engine_runtime}/" DESTINATION "bin/assets/engine")
endfunction() endfunction()
install(TARGETS destrum install(TARGETS destrum destrum_assets
ARCHIVE DESTINATION lib ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib LIBRARY DESTINATION lib
RUNTIME DESTINATION bin) RUNTIME DESTINATION bin)
@@ -0,0 +1,23 @@
#ifndef DESTRUM_MODELFILE_H
#define DESTRUM_MODELFILE_H
#include <cstdint>
#include <filesystem>
#include <span>
#include <string_view>
#include <vector>
#include <destrum/Util/ModelDoc.h>
namespace ModelFile {
inline constexpr std::uint32_t Version = 1;
inline constexpr std::string_view Extension = ".dmodel";
[[nodiscard]] std::vector<std::uint8_t> Write(const ModelDoc::ModelAsset& model);
[[nodiscard]] ModelDoc::ModelAsset Read(std::span<const std::uint8_t> bytes);
void WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path);
[[nodiscard]] ModelDoc::ModelAsset ReadFromFile(const std::filesystem::path& path);
}
#endif // DESTRUM_MODELFILE_H
@@ -0,0 +1,43 @@
#ifndef CPUMESH_H
#define CPUMESH_H
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include <glm/vec3.hpp>
#include <glm/vec4.hpp>
#include <destrum/Graphics/Skeleton.h>
#include <destrum/Assets/AssetReference.h>
// ─── CPU Mesh ─────────────────────────────────────────────────────────────────
struct CPUMesh {
struct Vertex {
glm::vec3 position;
float uv_x{};
glm::vec3 normal;
float uv_y{};
glm::vec4 tangent;
};
struct SkinningData {
glm::vec<4, std::uint32_t> jointIds;
glm::vec4 weights;
};
std::vector<std::uint32_t> indices;
std::vector<Vertex> vertices;
std::vector<SkinningData> skinningData; // empty if no skeleton
std::string name;
AssetReference assetReference;
glm::vec3 minPos;
glm::vec3 maxPos;
std::optional<Skeleton> skeleton; // present only for skinned meshes
};
#endif // CPUMESH_H
@@ -13,37 +13,8 @@
#include <glm/mat4x4.hpp> #include <glm/mat4x4.hpp>
#include <destrum/Graphics/Resources/Buffer.h> #include <destrum/Graphics/Resources/Buffer.h>
#include <destrum/Graphics/Resources/CPUMesh.h>
#include <destrum/Graphics/Frustum.h> #include <destrum/Graphics/Frustum.h>
#include <destrum/Graphics/Skeleton.h>
#include <destrum/Assets/AssetReference.h>
// ─── CPU Mesh ─────────────────────────────────────────────────────────────────
struct CPUMesh {
struct Vertex {
glm::vec3 position;
float uv_x{};
glm::vec3 normal;
float uv_y{};
glm::vec4 tangent;
};
struct SkinningData {
glm::vec<4, std::uint32_t> jointIds;
glm::vec4 weights;
};
std::vector<std::uint32_t> indices;
std::vector<Vertex> vertices;
std::vector<SkinningData> skinningData; // empty if no skeleton
std::string name;
AssetReference assetReference;
glm::vec3 minPos;
glm::vec3 maxPos;
std::optional<Skeleton> skeleton; // present only for skinned meshes
};
// ─── GPU Mesh ───────────────────────────────────────────────────────────────── // ─── GPU Mesh ─────────────────────────────────────────────────────────────────
+3 -1
View File
@@ -30,7 +30,9 @@
#include <glm/gtc/quaternion.hpp> #include <glm/gtc/quaternion.hpp>
#include <glm/gtc/type_ptr.hpp> #include <glm/gtc/type_ptr.hpp>
#include <destrum/Graphics/Resources/Mesh.h> #include <spdlog/spdlog.h>
#include <destrum/Graphics/Resources/CPUMesh.h>
#include <destrum/Assets/AssetReference.h> #include <destrum/Assets/AssetReference.h>
#include <destrum/Graphics/SkeletalAnimation.h> #include <destrum/Graphics/SkeletalAnimation.h>
#include <destrum/Graphics/Skeleton.h> #include <destrum/Graphics/Skeleton.h>
+444
View File
@@ -0,0 +1,444 @@
#include <destrum/Assets/ModelFile.h>
#include <array>
#include <bit>
#include <cstring>
#include <fstream>
#include <stdexcept>
#include <string>
#include <type_traits>
namespace {
static_assert(std::endian::native == std::endian::little, "Model files are stored little endian");
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<char, 4> Magic{'D', 'M', 'D', 'L'};
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) {
writer.WriteString(reference.path);
writer.WriteString(reference.subresource);
}
AssetReference ReadAssetReference(BinaryReader& reader) {
AssetReference reference;
reference.path = reader.ReadString();
reference.subresource = reader.ReadString();
return reference;
}
void WriteSkeleton(BinaryWriter& writer, const Skeleton& skeleton) {
WriteAssetReference(writer, skeleton.assetReference);
writer.WriteCount(skeleton.hierarchy.size());
for (const Skeleton::JointNode& node : skeleton.hierarchy) {
writer.Write(node.id);
writer.WriteArray(node.children);
}
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);
writer.Write(joint.localTranslation);
writer.Write(joint.localRotation);
writer.Write(joint.localScale);
}
writer.WriteStrings(skeleton.jointNames);
writer.WriteArray(skeleton.parentIndex);
writer.Write(skeleton.rootPreTransform);
}
Skeleton ReadSkeleton(BinaryReader& reader) {
Skeleton skeleton;
skeleton.assetReference = ReadAssetReference(reader);
skeleton.hierarchy.resize(reader.ReadCount(sizeof(JointId)));
for (Skeleton::JointNode& node : skeleton.hierarchy) {
node.id = reader.Read<JointId>();
node.children = reader.ReadArray<JointId>();
}
skeleton.inverseBindMatrices = reader.ReadArray<glm::mat4>();
skeleton.joints.resize(reader.ReadCount(sizeof(JointId)));
for (Joint& joint : skeleton.joints) {
joint.id = reader.Read<JointId>();
joint.localTranslation = reader.Read<glm::vec3>();
joint.localRotation = reader.Read<glm::quat>();
joint.localScale = reader.Read<glm::vec3>();
}
skeleton.jointNames = reader.ReadStrings();
skeleton.parentIndex = reader.ReadArray<int>();
skeleton.rootPreTransform = reader.Read<glm::mat4>();
return skeleton;
}
void WriteMesh(BinaryWriter& writer, const CPUMesh& mesh) {
writer.WriteArray(mesh.indices);
writer.WriteArray(mesh.vertices);
writer.WriteArray(mesh.skinningData);
writer.WriteString(mesh.name);
WriteAssetReference(writer, mesh.assetReference);
writer.Write(mesh.minPos);
writer.Write(mesh.maxPos);
writer.WriteBool(mesh.skeleton.has_value());
if (mesh.skeleton) {
WriteSkeleton(writer, *mesh.skeleton);
}
}
CPUMesh ReadMesh(BinaryReader& reader) {
CPUMesh mesh;
mesh.indices = reader.ReadArray<std::uint32_t>();
mesh.vertices = reader.ReadArray<CPUMesh::Vertex>();
mesh.skinningData = reader.ReadArray<CPUMesh::SkinningData>();
mesh.name = reader.ReadString();
mesh.assetReference = ReadAssetReference(reader);
mesh.minPos = reader.Read<glm::vec3>();
mesh.maxPos = reader.Read<glm::vec3>();
if (reader.ReadBool()) {
mesh.skeleton = ReadSkeleton(reader);
}
return mesh;
}
void WritePrimitive(BinaryWriter& writer, const ModelDoc::MeshPrimitive& primitive) {
WriteMesh(writer, primitive.mesh);
writer.Write(primitive.materialIndex);
writer.WriteString(primitive.nodeName);
writer.Write(primitive.sourceMeshIndex);
writer.WriteBool(primitive.skinned);
writer.Write(primitive.nodeWorldTransform);
}
ModelDoc::MeshPrimitive ReadPrimitive(BinaryReader& reader) {
ModelDoc::MeshPrimitive primitive;
primitive.mesh = ReadMesh(reader);
primitive.materialIndex = reader.Read<std::uint32_t>();
primitive.nodeName = reader.ReadString();
primitive.sourceMeshIndex = reader.Read<std::uint32_t>();
primitive.skinned = reader.ReadBool();
primitive.nodeWorldTransform = reader.Read<glm::mat4>();
return primitive;
}
void WriteTexture(BinaryWriter& writer, const ModelDoc::TextureInfo& texture) {
writer.Write(static_cast<std::uint32_t>(texture.semantic));
writer.WriteString(texture.rawPath);
writer.WriteString(texture.path);
writer.Write(static_cast<std::uint32_t>(texture.assimpType));
writer.Write(texture.slot);
writer.WriteBool(texture.embedded);
}
ModelDoc::TextureInfo ReadTexture(BinaryReader& reader) {
ModelDoc::TextureInfo texture;
texture.semantic = static_cast<ModelDoc::TextureSemantic>(reader.Read<std::uint32_t>());
texture.rawPath = reader.ReadString();
texture.path = reader.ReadString();
texture.assimpType = static_cast<aiTextureType>(reader.Read<std::uint32_t>());
texture.slot = reader.Read<std::uint32_t>();
texture.embedded = reader.ReadBool();
return texture;
}
void WriteMaterial(BinaryWriter& writer, const ModelDoc::MaterialInfo& material) {
writer.WriteString(material.name);
WriteAssetReference(writer, material.assetReference);
writer.Write(material.baseColor);
writer.Write(material.emissiveColor);
writer.Write(material.metallicFactor);
writer.Write(material.roughnessFactor);
writer.Write(material.opacity);
writer.WriteCount(material.textures.size());
for (const ModelDoc::TextureInfo& texture : material.textures) {
WriteTexture(writer, texture);
}
}
ModelDoc::MaterialInfo ReadMaterial(BinaryReader& reader) {
ModelDoc::MaterialInfo material;
material.name = reader.ReadString();
material.assetReference = ReadAssetReference(reader);
material.baseColor = reader.Read<glm::vec4>();
material.emissiveColor = reader.Read<glm::vec3>();
material.metallicFactor = reader.Read<float>();
material.roughnessFactor = reader.Read<float>();
material.opacity = reader.Read<float>();
material.textures.resize(reader.ReadCount(1));
for (ModelDoc::TextureInfo& texture : material.textures) {
texture = ReadTexture(reader);
}
return material;
}
void WriteAnimation(BinaryWriter& writer, const SkeletalAnimation& animation) {
WriteAssetReference(writer, animation.assetReference);
writer.WriteCount(animation.tracks.size());
for (const SkeletalAnimation::Track& track : animation.tracks) {
writer.Write(track.jointIndex);
writer.WriteArray(track.keyframes);
}
writer.Write(animation.duration);
writer.WriteBool(animation.looped);
writer.WriteString(animation.name);
writer.Write(animation.startFrame);
writer.WriteCount(animation.events.size());
for (const auto& [frame, events] : animation.events) {
writer.Write(frame);
writer.WriteStrings(events);
}
}
SkeletalAnimation ReadAnimation(BinaryReader& reader) {
SkeletalAnimation animation;
animation.assetReference = ReadAssetReference(reader);
animation.tracks.resize(reader.ReadCount(sizeof(std::uint32_t)));
for (SkeletalAnimation::Track& track : animation.tracks) {
track.jointIndex = reader.Read<std::uint32_t>();
track.keyframes = reader.ReadArray<SkeletalAnimation::Keyframe>();
}
animation.duration = reader.Read<float>();
animation.looped = reader.ReadBool();
animation.name = reader.ReadString();
animation.startFrame = reader.Read<int>();
const std::size_t eventFrameCount = reader.ReadCount(sizeof(int));
for (std::size_t i = 0; i < eventFrameCount; ++i) {
const int frame = reader.Read<int>();
animation.events[frame] = reader.ReadStrings();
}
return animation;
}
}
std::vector<std::uint8_t> ModelFile::Write(const ModelDoc::ModelAsset& model) {
BinaryWriter writer;
writer.Write(Magic);
writer.Write(Version);
writer.WriteString(model.sourcePath);
writer.WriteCount(model.primitives.size());
for (const ModelDoc::MeshPrimitive& primitive : model.primitives) {
WritePrimitive(writer, primitive);
}
writer.WriteCount(model.materials.size());
for (const ModelDoc::MaterialInfo& material : model.materials) {
WriteMaterial(writer, material);
}
WriteSkeleton(writer, model.skeleton);
writer.WriteCount(model.animations.size());
for (const SkeletalAnimation& animation : model.animations) {
WriteAnimation(writer, animation);
}
return writer.TakeBytes();
}
ModelDoc::ModelAsset ModelFile::Read(std::span<const std::uint8_t> bytes) {
BinaryReader reader{bytes};
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;
model.sourcePath = reader.ReadString();
model.primitives.resize(reader.ReadCount(1));
for (ModelDoc::MeshPrimitive& primitive : model.primitives) {
primitive = ReadPrimitive(reader);
}
model.materials.resize(reader.ReadCount(1));
for (ModelDoc::MaterialInfo& material : model.materials) {
material = ReadMaterial(reader);
}
model.skeleton = ReadSkeleton(reader);
model.animations.resize(reader.ReadCount(1));
for (SkeletalAnimation& animation : model.animations) {
animation = ReadAnimation(reader);
}
if (!reader.AtEnd()) {
throw std::runtime_error("Cooked model file has unexpected data at the end");
}
return model;
}
void ModelFile::WriteToFile(const ModelDoc::ModelAsset& model, const std::filesystem::path& path) {
const std::vector<std::uint8_t> 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<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) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) {
throw std::runtime_error("Failed to open model 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 model file: " + path.string());
}
return Read(bytes);
}
+4 -1
View File
@@ -8,7 +8,10 @@ set_target_properties(destrum_tests PROPERTIES
CXX_EXTENSIONS OFF CXX_EXTENSIONS OFF
) )
target_compile_definitions(destrum_tests PRIVATE SDL_MAIN_HANDLED) target_compile_definitions(destrum_tests PRIVATE
SDL_MAIN_HANDLED
DESTRUM_ENGINE_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/destrum/assets_src"
)
target_link_libraries(destrum_tests PRIVATE destrum::destrum) target_link_libraries(destrum_tests PRIVATE destrum::destrum)
destrum_enable_warnings(destrum_tests) destrum_enable_warnings(destrum_tests)
+187
View File
@@ -8,6 +8,7 @@
#include <destrum/Event.h> #include <destrum/Event.h>
#include <destrum/Assets/AssetReference.h> #include <destrum/Assets/AssetReference.h>
#include <destrum/Assets/ModelFile.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>
@@ -485,6 +486,189 @@ namespace {
"LightComponent range must stay positive"); "LightComponent range must stay positive");
} }
ModelDoc::ModelAsset makeTestModelAsset()
{
Skeleton skeleton;
skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"};
skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}};
skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}};
skeleton.joints = {
{.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}},
{.id = 1, .localScale = glm::vec3{2.0f}},
};
skeleton.jointNames = {"root", "spine"};
buildParentIndex(skeleton);
CPUMesh mesh;
mesh.indices = {0, 1, 2};
mesh.vertices = {
{.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}},
{.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}},
{.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f},
};
mesh.skinningData = {
{.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}},
{.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
{.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
};
mesh.name = "body";
mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"};
mesh.minPos = glm::vec3{0.0f};
mesh.maxPos = glm::vec3{1.0f};
mesh.skeleton = skeleton;
ModelDoc::MeshPrimitive primitive;
primitive.mesh = mesh;
primitive.materialIndex = 0;
primitive.nodeName = "Body";
primitive.sourceMeshIndex = 3;
primitive.skinned = true;
primitive.nodeWorldTransform = glm::mat4{3.0f};
ModelDoc::MaterialInfo material;
material.name = "skin";
material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"};
material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f};
material.emissiveColor = glm::vec3{0.1f};
material.metallicFactor = 0.2f;
material.roughnessFactor = 0.9f;
material.opacity = 0.5f;
material.textures = {{
.semantic = ModelDoc::TextureSemantic::BaseColor,
.rawPath = "*0",
.path = "textures/skin.png",
.assimpType = aiTextureType_DIFFUSE,
.slot = 1,
.embedded = true,
}};
SkeletalAnimation animation;
animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"};
animation.tracks = {{
.jointIndex = 1,
.keyframes = {
{.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity<glm::quat>(), .scale = glm::vec3{1.0f}},
{.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}},
},
}};
animation.duration = 0.5f;
animation.looped = false;
animation.name = "wave";
animation.startFrame = 4;
animation.events = {{2, {"footstep", "dust"}}};
ModelDoc::ModelAsset model;
model.sourcePath = "engine://character.fbx";
model.primitives = {primitive};
model.materials = {material};
model.skeleton = skeleton;
model.animations = {animation};
return model;
}
bool modelFileReadThrows(const std::vector<std::uint8_t>& bytes)
{
try {
(void)ModelFile::Read(bytes);
} catch (const std::exception&) {
return true;
}
return false;
}
void testModelFileRoundTrip()
{
const auto bytes = ModelFile::Write(makeTestModelAsset());
const auto loaded = ModelFile::Read(bytes);
check(ModelFile::Write(loaded) == bytes,
"a cooked model must write back to exactly the same bytes");
check(loaded.sourcePath == "engine://character.fbx",
"cooked models must keep their source path");
check(loaded.primitives.size() == 1, "cooked models must keep their primitives");
const auto& primitive = loaded.primitives.front();
check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned,
"cooked primitives must keep their import metadata");
check(primitive.mesh.indices == std::vector<std::uint32_t>{0, 1, 2},
"cooked meshes must keep their indices");
check(primitive.mesh.vertices.size() == 3 &&
primitive.mesh.vertices[0].uv_x == 0.25f &&
primitive.mesh.vertices[1].tangent.w == -1.0f,
"cooked meshes must keep their vertices");
check(primitive.mesh.skinningData[0].jointIds.y == 1 &&
primitive.mesh.skinningData[0].weights.x == 0.5f,
"cooked meshes must keep their skinning data");
check(primitive.mesh.skeleton.has_value() &&
primitive.mesh.skeleton->jointNames.size() == 2,
"cooked skinned meshes must keep their skeleton");
check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f,
"cooked models must keep their materials");
const auto& texture = loaded.materials.front().textures.front();
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
texture.path == "textures/skin.png" &&
texture.assimpType == aiTextureType_DIFFUSE &&
texture.embedded,
"cooked materials must keep their textures");
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
loaded.skeleton.parentIndex == std::vector<int>{-1, 0} &&
loaded.skeleton.joints[1].localScale == glm::vec3{2.0f},
"cooked models must keep their skeleton");
check(loaded.animations.size() == 1, "cooked models must keep their animations");
const auto& animation = loaded.animations.front();
check(animation.name == "wave" && !animation.looped && animation.startFrame == 4,
"cooked animations must keep their settings");
check(animation.tracks.front().keyframes[1].translation.y == 2.0f &&
animation.tracks.front().keyframes[1].rotation.x == 1.0f,
"cooked animations must keep their keyframes");
check(animation.events.at(2) == std::vector<std::string>{"footstep", "dust"},
"cooked animations must keep their events");
}
void testModelFileRejectsBadData()
{
const auto bytes = ModelFile::Write(makeTestModelAsset());
auto truncated = bytes;
truncated.resize(bytes.size() / 2);
check(modelFileReadThrows(truncated), "truncated cooked models must be rejected");
auto wrongMagic = bytes;
wrongMagic[0] = 'X';
check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected");
auto wrongVersion = bytes;
wrongVersion[4] = static_cast<std::uint8_t>(wrongVersion[4] + 1);
check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected");
auto trailingData = bytes;
trailingData.push_back(0);
check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected");
}
void testModelFileFromImportedModel()
{
const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx";
const auto imported = ModelDoc::LoadModel(source.generic_string());
const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel";
ModelFile::WriteToFile(imported, path);
const auto loaded = ModelFile::ReadFromFile(path);
std::filesystem::remove(path);
check(!imported.primitives.empty(), "the test model must import with at least one primitive");
check(loaded.primitives.size() == imported.primitives.size() &&
loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() &&
loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices,
"an imported model must survive being cooked");
check(ModelFile::Write(loaded) == ModelFile::Write(imported),
"a cooked imported model must match the import exactly");
}
void testSimpleColorMaterial() void testSimpleColorMaterial()
{ {
const auto material = Material::SimpleColor( const auto material = Material::SimpleColor(
@@ -600,6 +784,9 @@ int main()
testSceneJoltSphereCollisions(); testSceneJoltSphereCollisions();
testAnimatorAssetReferences(); testAnimatorAssetReferences();
testLightComponentRoundTrip(); testLightComponentRoundTrip();
testModelFileRoundTrip();
testModelFileRejectsBadData();
testModelFileFromImportedModel();
testSimpleColorMaterial(); testSimpleColorMaterial();
testAssetReferenceCacheKeys(); testAssetReferenceCacheKeys();
testTriggerZone(); testTriggerZone();