feat: add basic format for models
This commit is contained in:
@@ -35,6 +35,7 @@ if (TARGET _internal_validate_engine_shaders)
|
||||
add_dependencies(ValidateShaders _internal_validate_engine_shaders)
|
||||
endif()
|
||||
|
||||
destrum_enable_warnings(destrum_assets)
|
||||
destrum_enable_warnings(destrum)
|
||||
destrum_enable_warnings(lightkeeper)
|
||||
|
||||
|
||||
+33
-10
@@ -1,5 +1,35 @@
|
||||
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
|
||||
"src/App.cpp"
|
||||
"src/Event.cpp"
|
||||
@@ -100,6 +130,8 @@ target_include_directories(destrum PRIVATE
|
||||
|
||||
target_link_libraries(destrum
|
||||
PUBLIC
|
||||
destrum_assets
|
||||
|
||||
volk::volk_headers
|
||||
vk-bootstrap::vk-bootstrap
|
||||
GPUOpen::VulkanMemoryAllocator
|
||||
@@ -137,15 +169,6 @@ else ()
|
||||
target_link_libraries(destrum PUBLIC SDL2::SDL2-static)
|
||||
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_RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/assets_runtime")
|
||||
#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")
|
||||
endfunction()
|
||||
|
||||
install(TARGETS destrum
|
||||
install(TARGETS destrum destrum_assets
|
||||
ARCHIVE DESTINATION lib
|
||||
LIBRARY DESTINATION lib
|
||||
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 <destrum/Graphics/Resources/Buffer.h>
|
||||
#include <destrum/Graphics/Resources/CPUMesh.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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
#include <glm/gtc/quaternion.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/Graphics/SkeletalAnimation.h>
|
||||
#include <destrum/Graphics/Skeleton.h>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,7 +8,10 @@ set_target_properties(destrum_tests PROPERTIES
|
||||
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)
|
||||
|
||||
destrum_enable_warnings(destrum_tests)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <destrum/Event.h>
|
||||
#include <destrum/Assets/AssetReference.h>
|
||||
#include <destrum/Assets/ModelFile.h>
|
||||
#include <destrum/FS/AssetFS.h>
|
||||
#include <destrum/ObjectModel/Component.h>
|
||||
#include <destrum/ObjectModel/GameObject.h>
|
||||
@@ -485,6 +486,189 @@ namespace {
|
||||
"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()
|
||||
{
|
||||
const auto material = Material::SimpleColor(
|
||||
@@ -600,6 +784,9 @@ int main()
|
||||
testSceneJoltSphereCollisions();
|
||||
testAnimatorAssetReferences();
|
||||
testLightComponentRoundTrip();
|
||||
testModelFileRoundTrip();
|
||||
testModelFileRejectsBadData();
|
||||
testModelFileFromImportedModel();
|
||||
testSimpleColorMaterial();
|
||||
testAssetReferenceCacheKeys();
|
||||
testTriggerZone();
|
||||
|
||||
Reference in New Issue
Block a user