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
+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);
}