diff --git a/destrum/assets_src/characterMedium.fbx b/destrum/assets_src/characterMedium.fbx new file mode 100644 index 0000000..fcd9c99 Binary files /dev/null and b/destrum/assets_src/characterMedium.fbx differ diff --git a/destrum/assets_src/cotw-capybara-male/source/capybara.fbx b/destrum/assets_src/cotw-capybara-male/source/capybara.fbx index e66cf41..09bbe38 100644 Binary files a/destrum/assets_src/cotw-capybara-male/source/capybara.fbx and b/destrum/assets_src/cotw-capybara-male/source/capybara.fbx differ diff --git a/destrum/assets_src/multiple_object_test.fbx b/destrum/assets_src/multiple_object_test.fbx new file mode 100644 index 0000000..1e1e0fa Binary files /dev/null and b/destrum/assets_src/multiple_object_test.fbx differ diff --git a/destrum/assets_src/run.fbx b/destrum/assets_src/run.fbx new file mode 100644 index 0000000..a1e18b1 Binary files /dev/null and b/destrum/assets_src/run.fbx differ diff --git a/destrum/assets_src/textures/criminalMaleA.png b/destrum/assets_src/textures/criminalMaleA.png new file mode 100644 index 0000000..c9b328c Binary files /dev/null and b/destrum/assets_src/textures/criminalMaleA.png differ diff --git a/destrum/include/destrum/Util/ModelDoc.h b/destrum/include/destrum/Util/ModelDoc.h new file mode 100644 index 0000000..ac674ec --- /dev/null +++ b/destrum/include/destrum/Util/ModelDoc.h @@ -0,0 +1,1129 @@ +#ifndef MODELDOC_H +#define MODELDOC_H + +// ModelDoc - CPU-side model import API using Assimp. +// +// Goals: +// - One unified entry point: ModelDoc::LoadModel(path, options) +// - Return meshes, materials, texture path metadata, skeleton and animations together. +// - Do NOT upload to Vulkan here. Keep GPU/resource-cache work outside this file. +// - Preserve material assignment per mesh primitive. +// - Support static and skinned meshes from the same API. +// +// Example: +// auto doc = ModelDoc::LoadModel("character.fbx"); +// for (const auto& prim : doc.primitives) { +// MeshId meshId = meshCache.addMesh(gfxDevice, prim.mesh); +// const auto& mat = doc.materials[prim.materialIndex]; +// // Load mat.textures where semantic == TextureSemantic::BaseColor, Normal, etc. +// } +// +// Link against: assimp + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ModelDoc { + +static constexpr std::uint32_t InvalidIndex = std::numeric_limits::max(); + +enum class MeshImportMode { + PerPrimitive, + MergedPerNode +}; + +enum class TextureSemantic { + BaseColor, + Diffuse, + Normal, + Metallic, + Roughness, + MetallicRoughness, + AmbientOcclusion, + Emissive, + Height, + Opacity, + Specular, + Unknown +}; + +struct TextureInfo { + TextureSemantic semantic = TextureSemantic::Unknown; + + // The raw string stored in the imported file. For embedded glb textures this + // is often something like "*0". + std::string rawPath; + + // Resolved external path when possible. For embedded textures this remains + // the same token as rawPath unless you add extraction later. + std::string path; + + // Assimp texture slot metadata. + aiTextureType assimpType = aiTextureType_NONE; + std::uint32_t slot = 0; + + // True when Assimp reports this texture as embedded in the model file. + bool embedded = false; +}; + +struct MaterialInfo { + std::string name; + + glm::vec4 baseColor{1.0f}; + glm::vec3 emissiveColor{0.0f}; + float metallicFactor = 0.0f; + float roughnessFactor = 1.0f; + float opacity = 1.0f; + + std::vector textures; +}; + +struct MeshPrimitive { + CPUMesh mesh; + + // Index into ModelAsset::materials. May be InvalidIndex if the source mesh + // had no material or material loading was disabled. + std::uint32_t materialIndex = InvalidIndex; + + // Source metadata for debugging/import tooling. + std::string nodeName; + std::uint32_t sourceMeshIndex = InvalidIndex; + bool skinned = false; + + // World transform of the source node. Static meshes are baked into vertex + // positions by default, but this is still useful for debugging. If + // LoadOptions::bakeStaticNodeTransforms is false, apply this transform when + // spawning your GameObject. + glm::mat4 nodeWorldTransform{1.0f}; +}; + +struct ModelAsset { + std::string sourcePath; + + std::vector primitives; + std::vector materials; + + Skeleton skeleton; + std::vector animations; + + bool IsSkinned() const { + return !skeleton.joints.empty(); + } + + bool HasAnimations() const { + return !animations.empty(); + } +}; + +struct LoadOptions { + MeshImportMode meshImportMode = MeshImportMode::MergedPerNode; + + bool loadMaterials = true; + bool loadSkeleton = true; + bool loadAnimations = true; + + bool flipUVs = true; + bool generateTangents = true; + bool generateSmoothNormals = true; + bool joinIdenticalVertices = true; + bool limitBoneWeights = true; + + // Keeps old behavior from your current loader: static mesh node transforms + // are baked into vertex positions. Skinned meshes always stay in bind-pose + // local space because the skinning shader applies joint matrices at runtime. + bool bakeStaticNodeTransforms = true; +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +struct NodeTRS { + glm::vec3 translation{0.0f, 0.0f, 0.0f}; + glm::quat rotation{1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec3 scale{1.0f, 1.0f, 1.0f}; +}; + +static glm::mat4 ToGLM(const aiMatrix4x4& m) { + return glm::transpose(glm::make_mat4(&m.a1)); +} + +static glm::vec3 ToGLM(const aiVector3D& v) { + return glm::vec3(v.x, v.y, v.z); +} + +static glm::quat ToGLM(const aiQuaternion& q) { + return glm::normalize(glm::quat(q.w, q.x, q.y, q.z)); +} + +static glm::vec4 ToGLM(const aiColor4D& c) { + return glm::vec4(c.r, c.g, c.b, c.a); +} + +static glm::vec3 SafeNormalize(const glm::vec3& v, const glm::vec3& fallback) { + const float len2 = glm::dot(v, v); + if (len2 <= std::numeric_limits::epsilon()) return fallback; + return v * (1.0f / std::sqrt(len2)); +} + +static void UpdateBounds(glm::vec3& mn, glm::vec3& mx, const glm::vec3& p) { + mn.x = std::min(mn.x, p.x); + mn.y = std::min(mn.y, p.y); + mn.z = std::min(mn.z, p.z); + + mx.x = std::max(mx.x, p.x); + mx.y = std::max(mx.y, p.y); + mx.z = std::max(mx.z, p.z); +} + +static NodeTRS DecomposeNodeTransform(const aiNode* node) { + NodeTRS out{}; + + aiVector3D scaling; + aiVector3D translation; + aiQuaternion rotation; + node->mTransformation.Decompose(scaling, rotation, translation); + + out.translation = ToGLM(translation); + out.rotation = ToGLM(rotation); + out.scale = ToGLM(scaling); + return out; +} + +static void CollectNodeDefaults(const aiNode* node, + std::unordered_map& defaults) { + if (!node) return; + + defaults[std::string(node->mName.C_Str())] = DecomposeNodeTransform(node); + + for (unsigned int c = 0; c < node->mNumChildren; ++c) + CollectNodeDefaults(node->mChildren[c], defaults); +} + +static std::unordered_map +BuildJointIndexMap(const Skeleton& skeleton) { + std::unordered_map map; + map.reserve(skeleton.jointNames.size()); + + for (std::size_t i = 0; i < skeleton.jointNames.size(); ++i) + map[skeleton.jointNames[i]] = static_cast(i); + + return map; +} + +static unsigned int MakeImportFlags(const LoadOptions& options) { + unsigned int flags = aiProcess_Triangulate; + + if (options.generateTangents) + flags |= aiProcess_CalcTangentSpace; + + if (options.joinIdenticalVertices) + flags |= aiProcess_JoinIdenticalVertices; + + if (options.generateSmoothNormals) + flags |= aiProcess_GenSmoothNormals; + + if (options.flipUVs) + flags |= aiProcess_FlipUVs; + + if (options.limitBoneWeights) + flags |= aiProcess_LimitBoneWeights; + + return flags; +} + + +static const aiScene* LoadScene(Assimp::Importer& importer, + const std::string& path, + const LoadOptions& options) { + const aiScene* scene = importer.ReadFile(path, MakeImportFlags(options)); + if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { + spdlog::error(std::string("Assimp failed to load '") + path + "': " + importer.GetErrorString()); + throw std::runtime_error(std::string("Assimp failed to load '") + path + "': " + importer.GetErrorString()); + } + return scene; +} + +static std::string ResolveTexturePath(const std::filesystem::path& modelDir, + const aiScene* scene, + const aiString& aiPath, + bool& embedded) { + const std::string raw = aiPath.C_Str(); + embedded = false; + + if (raw.empty()) + return {}; + + if (scene && scene->GetEmbeddedTexture(raw.c_str())) { + embedded = true; + return raw; + } + + if (!raw.empty() && raw[0] == '*') { + embedded = true; + return raw; + } + + const std::filesystem::path p(raw); + if (p.is_absolute()) + return p.lexically_normal().generic_string(); + + return (modelDir / p).lexically_normal().generic_string(); +} + +static void AddMaterialTextureSlots(const aiScene* scene, + const aiMaterial* material, + const std::filesystem::path& modelDir, + aiTextureType type, + TextureSemantic semantic, + std::vector& outTextures) { + if (!material) return; + + const unsigned int count = material->GetTextureCount(type); + for (unsigned int i = 0; i < count; ++i) { + aiString texPath; + if (material->GetTexture(type, i, &texPath) != AI_SUCCESS) + continue; + + TextureInfo tex{}; + tex.semantic = semantic; + tex.rawPath = texPath.C_Str(); + tex.assimpType = type; + tex.slot = i; + tex.path = ResolveTexturePath(modelDir, scene, texPath, tex.embedded); + + outTextures.push_back(std::move(tex)); + } +} + +static MaterialInfo LoadMaterialInfo(const aiScene* scene, + const aiMaterial* material, + const std::filesystem::path& modelDir, + std::uint32_t materialIndex) { + MaterialInfo out{}; + out.name = "material_" + std::to_string(materialIndex); + + if (!material) + return out; + + aiString name; + if (material->Get(AI_MATKEY_NAME, name) == AI_SUCCESS && name.length > 0) + out.name = name.C_Str(); + + aiColor4D color{}; + if (aiGetMaterialColor(material, AI_MATKEY_BASE_COLOR, &color) == AI_SUCCESS) { + out.baseColor = ToGLM(color); + } else if (aiGetMaterialColor(material, AI_MATKEY_COLOR_DIFFUSE, &color) == AI_SUCCESS) { + out.baseColor = ToGLM(color); + } + + if (aiGetMaterialColor(material, AI_MATKEY_COLOR_EMISSIVE, &color) == AI_SUCCESS) { + out.emissiveColor = glm::vec3(color.r, color.g, color.b); + } + + float value = 0.0f; + if (material->Get(AI_MATKEY_METALLIC_FACTOR, value) == AI_SUCCESS) + out.metallicFactor = value; + + value = 1.0f; + if (material->Get(AI_MATKEY_ROUGHNESS_FACTOR, value) == AI_SUCCESS) + out.roughnessFactor = value; + + value = 1.0f; + if (material->Get(AI_MATKEY_OPACITY, value) == AI_SUCCESS) + out.opacity = value; + + // PBR/glTF paths. + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_BASE_COLOR, + TextureSemantic::BaseColor, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_NORMAL_CAMERA, + TextureSemantic::Normal, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_METALNESS, + TextureSemantic::Metallic, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_DIFFUSE_ROUGHNESS, + TextureSemantic::Roughness, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_AMBIENT_OCCLUSION, + TextureSemantic::AmbientOcclusion, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_EMISSION_COLOR, + TextureSemantic::Emissive, out.textures); + + // Traditional material paths / fallbacks. + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_DIFFUSE, + TextureSemantic::Diffuse, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_NORMALS, + TextureSemantic::Normal, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_HEIGHT, + TextureSemantic::Height, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_OPACITY, + TextureSemantic::Opacity, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_SPECULAR, + TextureSemantic::Specular, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_EMISSIVE, + TextureSemantic::Emissive, out.textures); + AddMaterialTextureSlots(scene, material, modelDir, aiTextureType_UNKNOWN, + TextureSemantic::Unknown, out.textures); + + return out; +} + +static std::vector LoadMaterials(const aiScene* scene, + const std::filesystem::path& modelDir) { + std::vector materials; + if (!scene || scene->mNumMaterials == 0) + return materials; + + materials.reserve(scene->mNumMaterials); + for (unsigned int i = 0; i < scene->mNumMaterials; ++i) + materials.push_back(LoadMaterialInfo(scene, scene->mMaterials[i], modelDir, i)); + + return materials; +} + +// ─── Mesh loading ───────────────────────────────────────────────────────────── + +static CPUMesh LoadAiMeshIntoCPUMesh(const aiMesh* mesh, + const std::string& name, + const glm::mat4& transform) { + CPUMesh out{}; + out.name = name; + + const size_t vertexCount = mesh->mNumVertices; + out.vertices.resize(vertexCount); + + const glm::mat3 world3 = glm::mat3(transform); + const glm::mat3 nrmMat = glm::transpose(glm::inverse(world3)); + const glm::mat3 tanMat = world3; + + glm::vec3 mn{ std::numeric_limits::infinity() }; + glm::vec3 mx{ -std::numeric_limits::infinity() }; + + for (size_t i = 0; i < vertexCount; ++i) { + CPUMesh::Vertex v{}; + + const aiVector3D& ap = mesh->mVertices[i]; + v.position = glm::vec3(transform * glm::vec4(ap.x, ap.y, ap.z, 1.0f)); + UpdateBounds(mn, mx, v.position); + + if (mesh->HasNormals()) { + const aiVector3D& an = mesh->mNormals[i]; + v.normal = SafeNormalize(nrmMat * glm::vec3(an.x, an.y, an.z), + glm::vec3(0.0f, 1.0f, 0.0f)); + } else { + v.normal = glm::vec3(0.0f, 1.0f, 0.0f); + } + + if (mesh->HasTextureCoords(0)) { + v.uv_x = mesh->mTextureCoords[0][i].x; + v.uv_y = mesh->mTextureCoords[0][i].y; + } else { + v.uv_x = 0.0f; + v.uv_y = 0.0f; + } + + if (mesh->HasTangentsAndBitangents()) { + const aiVector3D& at = mesh->mTangents[i]; + const aiVector3D& ab = mesh->mBitangents[i]; + + const glm::vec3 t3 = SafeNormalize(tanMat * glm::vec3(at.x, at.y, at.z), + glm::vec3(1.0f, 0.0f, 0.0f)); + const glm::vec3 b3 = SafeNormalize(tanMat * glm::vec3(ab.x, ab.y, ab.z), + glm::vec3(0.0f, 1.0f, 0.0f)); + const glm::vec3 n3 = v.normal; + const float sign = (glm::dot(glm::cross(n3, t3), b3) < 0.0f) ? -1.0f : 1.0f; + v.tangent = glm::vec4(t3, sign); + } else { + v.tangent = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); + } + + out.vertices[i] = v; + } + + out.minPos = vertexCount > 0 ? mn : glm::vec3(0.0f); + out.maxPos = vertexCount > 0 ? mx : glm::vec3(0.0f); + + if (mesh->HasFaces()) { + out.indices.reserve(size_t(mesh->mNumFaces) * 3); + for (unsigned int f = 0; f < mesh->mNumFaces; ++f) { + const aiFace& face = mesh->mFaces[f]; + for (unsigned int k = 0; k < face.mNumIndices; ++k) + out.indices.push_back(static_cast(face.mIndices[k])); + } + } else { + out.indices.resize(vertexCount); + for (size_t i = 0; i < vertexCount; ++i) + out.indices[i] = static_cast(i); + } + + return out; +} + +static void AppendMesh(CPUMesh& dst, const CPUMesh& src) { + const std::uint32_t base = static_cast(dst.vertices.size()); + + dst.vertices.insert(dst.vertices.end(), src.vertices.begin(), src.vertices.end()); + + dst.indices.reserve(dst.indices.size() + src.indices.size()); + for (std::uint32_t idx : src.indices) + dst.indices.push_back(base + idx); + + if (!src.skinningData.empty()) { + dst.skinningData.insert(dst.skinningData.end(), src.skinningData.begin(), src.skinningData.end()); + } + + dst.minPos = glm::vec3( + std::min(dst.minPos.x, src.minPos.x), + std::min(dst.minPos.y, src.minPos.y), + std::min(dst.minPos.z, src.minPos.z) + ); + dst.maxPos = glm::vec3( + std::max(dst.maxPos.x, src.maxPos.x), + std::max(dst.maxPos.y, src.maxPos.y), + std::max(dst.maxPos.z, src.maxPos.z) + ); +} + +// ─── Skeleton loading ───────────────────────────────────────────────────────── + +static Skeleton LoadSkeleton(const aiScene* scene) { + Skeleton skeleton; + skeleton.rootPreTransform = glm::mat4{1.0f}; + + std::unordered_map boneOffsets; + for (unsigned int m = 0; m < scene->mNumMeshes; ++m) { + const aiMesh* mesh = scene->mMeshes[m]; + for (unsigned int b = 0; b < mesh->mNumBones; ++b) { + const aiBone* bone = mesh->mBones[b]; + std::string boneName(bone->mName.C_Str()); + if (!boneOffsets.count(boneName)) + boneOffsets[boneName] = ToGLM(bone->mOffsetMatrix); + } + } + + if (boneOffsets.empty()) + return skeleton; + + JointId nextId = 0; + + struct StackItem { + const aiNode* node; + int parentIdx; + glm::mat4 accWorld; + }; + + std::vector stack{{ scene->mRootNode, -1, glm::mat4{1.0f} }}; + + while (!stack.empty()) { + auto [node, parentIdx, accWorld] = stack.back(); + stack.pop_back(); + + std::string name(node->mName.C_Str()); + int myIdx = parentIdx; + glm::mat4 myAccWorld = accWorld; + + if (boneOffsets.count(name)) { + const JointId id = nextId++; + myIdx = static_cast(id); + + Joint joint{}; + joint.id = id; + skeleton.joints.push_back(joint); + skeleton.jointNames.push_back(name); + skeleton.inverseBindMatrices.push_back(boneOffsets[name]); + + while (skeleton.hierarchy.size() <= id) + skeleton.hierarchy.push_back({}); + + skeleton.hierarchy[id].id = id; + + if (parentIdx >= 0) { + skeleton.hierarchy[parentIdx].children.push_back(id); + } else { + skeleton.rootPreTransform = accWorld; + glm::mat4& m = skeleton.rootPreTransform; + + m[0] = glm::vec4(SafeNormalize(glm::vec3(m[0]), glm::vec3(1.0f, 0.0f, 0.0f)), 0.0f); + m[1] = glm::vec4(SafeNormalize(glm::vec3(m[1]), glm::vec3(0.0f, 1.0f, 0.0f)), 0.0f); + m[2] = glm::vec4(SafeNormalize(glm::vec3(m[2]), glm::vec3(0.0f, 0.0f, 1.0f)), 0.0f); + } + + myAccWorld = glm::mat4{1.0f}; + } else { + myAccWorld = accWorld * ToGLM(node->mTransformation); + } + + for (int c = static_cast(node->mNumChildren) - 1; c >= 0; --c) + stack.push_back({ node->mChildren[c], myIdx, myAccWorld }); + } + + buildParentIndex(skeleton); + return skeleton; +} + +static void LoadSkinningData(CPUMesh& cpuMesh, + const aiMesh* aiMesh, + const Skeleton& skeleton) { + if (!aiMesh->HasBones() || skeleton.joints.empty()) + return; + + cpuMesh.skinningData.assign(cpuMesh.vertices.size(), + CPUMesh::SkinningData{ {0, 0, 0, 0}, {0.0f, 0.0f, 0.0f, 0.0f} }); + + const auto jointIndexByName = BuildJointIndexMap(skeleton); + std::vector weightCount(cpuMesh.vertices.size(), 0); + + for (unsigned int b = 0; b < aiMesh->mNumBones; ++b) { + const aiBone* bone = aiMesh->mBones[b]; + std::string boneName(bone->mName.C_Str()); + + const auto jointIt = jointIndexByName.find(boneName); + if (jointIt == jointIndexByName.end()) + continue; + + const std::uint32_t jointIdx = jointIt->second; + + for (unsigned int w = 0; w < bone->mNumWeights; ++w) { + const unsigned int vertIdx = bone->mWeights[w].mVertexId; + if (vertIdx >= cpuMesh.skinningData.size()) + continue; + + const int slot = weightCount[vertIdx]; + if (slot >= 4) + continue; + + cpuMesh.skinningData[vertIdx].jointIds[slot] = jointIdx; + cpuMesh.skinningData[vertIdx].weights[slot] = bone->mWeights[w].mWeight; + ++weightCount[vertIdx]; + } + } + + for (auto& skin : cpuMesh.skinningData) { + const float sum = skin.weights[0] + skin.weights[1] + skin.weights[2] + skin.weights[3]; + + if (sum > std::numeric_limits::epsilon()) { + skin.weights[0] /= sum; + skin.weights[1] /= sum; + skin.weights[2] /= sum; + skin.weights[3] /= sum; + } else { + skin.jointIds = {0, 0, 0, 0}; + skin.weights = {1.0f, 0.0f, 0.0f, 0.0f}; + } + } +} + +// ─── Animation loading ──────────────────────────────────────────────────────── + +static glm::vec3 SampleVectorKeys(const aiVectorKey* keys, + unsigned int count, + double tick, + const glm::vec3& fallback) { + if (!keys || count == 0) return fallback; + if (count == 1 || tick <= keys[0].mTime) return ToGLM(keys[0].mValue); + + for (unsigned int i = 0; i + 1 < count; ++i) { + const double t0 = keys[i].mTime; + const double t1 = keys[i + 1].mTime; + + if (tick <= t1) { + const double denom = t1 - t0; + const float alpha = denom > 0.0 + ? static_cast((tick - t0) / denom) + : 0.0f; + + return glm::mix(ToGLM(keys[i].mValue), ToGLM(keys[i + 1].mValue), alpha); + } + } + + return ToGLM(keys[count - 1].mValue); +} + +static glm::quat SampleQuatKeys(const aiQuatKey* keys, + unsigned int count, + double tick, + const glm::quat& fallback) { + if (!keys || count == 0) return fallback; + if (count == 1 || tick <= keys[0].mTime) return ToGLM(keys[0].mValue); + + for (unsigned int i = 0; i + 1 < count; ++i) { + const double t0 = keys[i].mTime; + const double t1 = keys[i + 1].mTime; + + if (tick <= t1) { + const double denom = t1 - t0; + const float alpha = denom > 0.0 + ? static_cast((tick - t0) / denom) + : 0.0f; + + return glm::normalize(glm::slerp(ToGLM(keys[i].mValue), + ToGLM(keys[i + 1].mValue), + alpha)); + } + } + + return ToGLM(keys[count - 1].mValue); +} + +static void AppendKeyTimes(std::vector& times, + const aiVectorKey* keys, + unsigned int count) { + if (!keys) return; + for (unsigned int i = 0; i < count; ++i) + times.push_back(keys[i].mTime); +} + +static void AppendKeyTimes(std::vector& times, + const aiQuatKey* keys, + unsigned int count) { + if (!keys) return; + for (unsigned int i = 0; i < count; ++i) + times.push_back(keys[i].mTime); +} + +static void SortAndUniqueKeyTimes(std::vector& times) { + std::sort(times.begin(), times.end()); + + constexpr double epsilon = 1e-6; + times.erase(std::unique(times.begin(), times.end(), + [](double a, double b) { return std::abs(a - b) <= epsilon; }), + times.end()); +} + +static double GetMaxAnimationKeyTick(const aiAnimation* aiAnim) { + double maxTick = 0.0; + + for (unsigned int c = 0; c < aiAnim->mNumChannels; ++c) { + const aiNodeAnim* ch = aiAnim->mChannels[c]; + + if (ch->mNumPositionKeys > 0) + maxTick = std::max(maxTick, ch->mPositionKeys[ch->mNumPositionKeys - 1].mTime); + if (ch->mNumRotationKeys > 0) + maxTick = std::max(maxTick, ch->mRotationKeys[ch->mNumRotationKeys - 1].mTime); + if (ch->mNumScalingKeys > 0) + maxTick = std::max(maxTick, ch->mScalingKeys[ch->mNumScalingKeys - 1].mTime); + } + + return maxTick; +} + +static std::vector LoadAnimations(const aiScene* scene, + const Skeleton& skeleton) { + std::vector result; + + if (!scene->HasAnimations()) + return result; + + if (skeleton.joints.empty()) { + std::cout << "[ModelDoc] Scene has " << scene->mNumAnimations + << " animation(s), but no skeleton joints were loaded.\n"; + return result; + } + + const auto jointIndexByName = BuildJointIndexMap(skeleton); + + std::unordered_map nodeDefaults; + CollectNodeDefaults(scene->mRootNode, nodeDefaults); + + result.reserve(scene->mNumAnimations); + + for (unsigned int a = 0; a < scene->mNumAnimations; ++a) { + const aiAnimation* aiAnim = scene->mAnimations[a]; + const double tps = aiAnim->mTicksPerSecond > 0.0 ? aiAnim->mTicksPerSecond : 25.0; + + const double durationTicks = aiAnim->mDuration > 0.0 + ? aiAnim->mDuration + : GetMaxAnimationKeyTick(aiAnim); + + SkeletalAnimation anim; + anim.name = aiAnim->mName.length > 0 + ? std::string(aiAnim->mName.C_Str()) + : ("Animation_" + std::to_string(a)); + anim.duration = static_cast(durationTicks / tps); + anim.looped = true; + + anim.tracks.reserve(aiAnim->mNumChannels); + + for (unsigned int c = 0; c < aiAnim->mNumChannels; ++c) { + const aiNodeAnim* ch = aiAnim->mChannels[c]; + const std::string boneName(ch->mNodeName.C_Str()); + + const auto jointIt = jointIndexByName.find(boneName); + if (jointIt == jointIndexByName.end()) + continue; + + const auto defaultIt = nodeDefaults.find(boneName); + const NodeTRS defaults = defaultIt != nodeDefaults.end() + ? defaultIt->second + : NodeTRS{}; + + std::vector keyTicks; + keyTicks.reserve(ch->mNumPositionKeys + ch->mNumRotationKeys + ch->mNumScalingKeys); + + AppendKeyTimes(keyTicks, ch->mPositionKeys, ch->mNumPositionKeys); + AppendKeyTimes(keyTicks, ch->mRotationKeys, ch->mNumRotationKeys); + AppendKeyTimes(keyTicks, ch->mScalingKeys, ch->mNumScalingKeys); + SortAndUniqueKeyTimes(keyTicks); + + if (keyTicks.empty()) + keyTicks.push_back(0.0); + + SkeletalAnimation::Track track; + track.jointIndex = jointIt->second; + track.keyframes.reserve(keyTicks.size()); + + for (double tick : keyTicks) { + SkeletalAnimation::Keyframe kf; + kf.time = static_cast(tick / tps); + kf.translation = SampleVectorKeys(ch->mPositionKeys, + ch->mNumPositionKeys, + tick, + defaults.translation); + kf.rotation = SampleQuatKeys(ch->mRotationKeys, + ch->mNumRotationKeys, + tick, + defaults.rotation); + kf.scale = SampleVectorKeys(ch->mScalingKeys, + ch->mNumScalingKeys, + tick, + defaults.scale); + + track.keyframes.push_back(kf); + } + + anim.tracks.push_back(std::move(track)); + } + + result.push_back(std::move(anim)); + } + + return result; +} + +// ─── Primitive collection ───────────────────────────────────────────────────── + +static std::string GetNodeName(const aiNode* node) { + if (node && node->mName.length > 0) + return node->mName.C_Str(); + return "node"; +} + +static std::string GetMeshName(const aiMesh* mesh, + const std::string& fallbackPrefix, + std::uint32_t sourceMeshIndex) { + if (mesh && mesh->mName.length > 0) + return mesh->mName.C_Str(); + return fallbackPrefix + "_mesh_" + std::to_string(sourceMeshIndex); +} + +static std::uint32_t GetMaterialIndex(const aiScene* scene, const aiMesh* mesh) { + if (!scene || !mesh) + return InvalidIndex; + + if (mesh->mMaterialIndex >= scene->mNumMaterials) + return InvalidIndex; + + return static_cast(mesh->mMaterialIndex); +} + +static MeshPrimitive BuildPrimitiveFromAiMesh(const aiScene* scene, + const aiMesh* aiMesh, + std::uint32_t sourceMeshIndex, + const std::string& name, + const std::string& nodeName, + const glm::mat4& nodeWorld, + const Skeleton& skeleton, + const LoadOptions& options) { + const bool isSkinned = aiMesh->HasBones() && !skeleton.joints.empty(); + + // Skinned meshes must stay in bind-pose local space. Static meshes can be + // baked like your old loader, or left local if you want to spawn nodes with + // transforms later. + const glm::mat4 meshTransform = isSkinned + ? glm::mat4{1.0f} + : (options.bakeStaticNodeTransforms ? nodeWorld : glm::mat4{1.0f}); + + MeshPrimitive primitive{}; + primitive.mesh = LoadAiMeshIntoCPUMesh(aiMesh, name, meshTransform); + primitive.materialIndex = options.loadMaterials ? GetMaterialIndex(scene, aiMesh) : InvalidIndex; + primitive.nodeName = nodeName; + primitive.sourceMeshIndex = sourceMeshIndex; + primitive.skinned = isSkinned; + primitive.nodeWorldTransform = nodeWorld; + + if (isSkinned) { + LoadSkinningData(primitive.mesh, aiMesh, skeleton); + primitive.mesh.skeleton = skeleton; + } + + return primitive; +} + +static void CollectPerPrimitiveMeshes(const aiScene* scene, + const Skeleton& skeleton, + const LoadOptions& options, + std::vector& outPrimitives) { + struct StackItem { + const aiNode* node; + glm::mat4 parentWorld; + }; + + std::vector stack{{ scene->mRootNode, glm::mat4{1.0f} }}; + + while (!stack.empty()) { + auto [node, parentWorld] = stack.back(); + stack.pop_back(); + + const glm::mat4 nodeWorld = parentWorld * ToGLM(node->mTransformation); + const std::string nodeName = GetNodeName(node); + + for (unsigned int c = 0; c < node->mNumChildren; ++c) + stack.push_back({ node->mChildren[c], nodeWorld }); + + for (unsigned int m = 0; m < node->mNumMeshes; ++m) { + const std::uint32_t sourceMeshIndex = node->mMeshes[m]; + const aiMesh* aiMesh = scene->mMeshes[sourceMeshIndex]; + + const std::string meshName = GetMeshName(aiMesh, nodeName, sourceMeshIndex); + outPrimitives.push_back(BuildPrimitiveFromAiMesh(scene, + aiMesh, + sourceMeshIndex, + meshName, + nodeName, + nodeWorld, + skeleton, + options)); + } + } +} + +static void CollectMergedPerNodeMeshes(const aiScene* scene, + const Skeleton& skeleton, + const LoadOptions& options, + std::vector& outPrimitives) { + struct StackItem { + const aiNode* node; + glm::mat4 parentWorld; + }; + + std::vector stack{{ scene->mRootNode, glm::mat4{1.0f} }}; + + while (!stack.empty()) { + auto [node, parentWorld] = stack.back(); + stack.pop_back(); + + const glm::mat4 nodeWorld = parentWorld * ToGLM(node->mTransformation); + const std::string nodeName = GetNodeName(node); + + for (unsigned int c = 0; c < node->mNumChildren; ++c) + stack.push_back({ node->mChildren[c], nodeWorld }); + + if (node->mNumMeshes == 0) + continue; + + // Important: only merge meshes that have the same material and skinning + // state. A single MeshPrimitive has one materialIndex, so merging + // different materials would lose submesh material assignment. + struct MergeKey { + std::uint32_t materialIndex = InvalidIndex; + bool skinned = false; + }; + + std::vector groups; + std::vector keys; + + for (unsigned int m = 0; m < node->mNumMeshes; ++m) { + const std::uint32_t sourceMeshIndex = node->mMeshes[m]; + const aiMesh* aiMesh = scene->mMeshes[sourceMeshIndex]; + + const MergeKey key{ + options.loadMaterials ? GetMaterialIndex(scene, aiMesh) : InvalidIndex, + aiMesh->HasBones() && !skeleton.joints.empty() + }; + + const std::string meshName = nodeName + "_mat_" + std::to_string(key.materialIndex); + MeshPrimitive prim = BuildPrimitiveFromAiMesh(scene, + aiMesh, + sourceMeshIndex, + meshName, + nodeName, + nodeWorld, + skeleton, + options); + + auto it = std::find_if(keys.begin(), keys.end(), [&](const MergeKey& k) { + return k.materialIndex == key.materialIndex && k.skinned == key.skinned; + }); + + if (it == keys.end()) { + keys.push_back(key); + groups.push_back(std::move(prim)); + } else { + const std::size_t groupIndex = static_cast(std::distance(keys.begin(), it)); + AppendMesh(groups[groupIndex].mesh, prim.mesh); + groups[groupIndex].mesh.name = meshName; + } + } + + for (auto& group : groups) + outPrimitives.push_back(std::move(group)); + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +static ModelAsset LoadModel(const std::string& path, const LoadOptions& options = {}) { + Assimp::Importer importer; + const aiScene* scene = LoadScene(importer, path, options); + + ModelAsset model{}; + model.sourcePath = path; + + const std::filesystem::path modelDir = std::filesystem::path(path).parent_path(); + + if (options.loadMaterials) + model.materials = LoadMaterials(scene, modelDir); + + if (options.loadSkeleton) { + model.skeleton = LoadSkeleton(scene); + std::cout << "[ModelDoc] Loaded skeleton with " << model.skeleton.joints.size() + << " joint(s).\n"; + } + + if (options.loadAnimations) + model.animations = LoadAnimations(scene, model.skeleton); + + if (options.meshImportMode == MeshImportMode::PerPrimitive) { + CollectPerPrimitiveMeshes(scene, model.skeleton, options, model.primitives); + } else { + CollectMergedPerNodeMeshes(scene, model.skeleton, options, model.primitives); + } + + std::cout << "[ModelDoc] Loaded model: " << path + << " | primitives: " << model.primitives.size() + << " | materials: " << model.materials.size() + << " | animations: " << model.animations.size() + << "\n"; + + return model; +} + +static const char* ToString(TextureSemantic semantic) { + switch (semantic) { + case TextureSemantic::BaseColor: return "BaseColor"; + case TextureSemantic::Diffuse: return "Diffuse"; + case TextureSemantic::Normal: return "Normal"; + case TextureSemantic::Metallic: return "Metallic"; + case TextureSemantic::Roughness: return "Roughness"; + case TextureSemantic::MetallicRoughness: return "MetallicRoughness"; + case TextureSemantic::AmbientOcclusion: return "AmbientOcclusion"; + case TextureSemantic::Emissive: return "Emissive"; + case TextureSemantic::Height: return "Height"; + case TextureSemantic::Opacity: return "Opacity"; + case TextureSemantic::Specular: return "Specular"; + default: return "Unknown"; + } +} + +// static std::vector LoadAnimationClips( +// const std::string& path, +// const Skeleton& targetSkeleton, +// const LoadOptions& options = {} +// ) { +// Assimp::Importer importer; +// const aiScene* scene = LoadScene(importer, path, options); +// +// return LoadAnimations(scene, targetSkeleton); +// } + +static const aiScene* LoadAnimationScene( + Assimp::Importer& importer, + const std::string& path +) { + if (!std::filesystem::exists(path)) { + throw std::runtime_error("Animation file does not exist: " + path); + } + + if (!std::filesystem::is_regular_file(path)) { + throw std::runtime_error("Animation path is not a regular file: " + path); + } + + if (std::filesystem::file_size(path) == 0) { + throw std::runtime_error("Animation file is empty: " + path); + } + + // Important: do not use mesh post-process flags for animation-only files. + const unsigned int flags = 0; + + const aiScene* scene = importer.ReadFile(path, flags); + + if (!scene) { + throw std::runtime_error( + std::string("Assimp failed to load animation file '") + + path + "': " + importer.GetErrorString() + ); + } + + if (!scene->mRootNode) { + throw std::runtime_error( + "Assimp loaded animation file but it has no root node: " + path + ); + } + + if (!scene->HasAnimations()) { + throw std::runtime_error( + "Animation file loaded, but contains no animations: " + path + ); + } + + if (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) { + spdlog::warn( + "Animation file '{}' was marked incomplete by Assimp, " + "but it has a root node and animations, so continuing.", + path + ); + } + + spdlog::info( + "Loaded animation scene '{}': meshes={}, animations={}, flags={}", + path, + scene->mNumMeshes, + scene->mNumAnimations, + scene->mFlags + ); + + return scene; +} + + static std::vector LoadAnimationClips( + const std::string& path, + const Skeleton& targetSkeleton +) { + Assimp::Importer importer; + const aiScene* scene = LoadAnimationScene(importer, path); + + return LoadAnimations(scene, targetSkeleton); +} + +} // namespace ModelDoc + +#endif // MODELDOC_H diff --git a/destrum/include/destrum/Util/ModelDocUtils.h b/destrum/include/destrum/Util/ModelDocUtils.h new file mode 100644 index 0000000..1aaaf32 --- /dev/null +++ b/destrum/include/destrum/Util/ModelDocUtils.h @@ -0,0 +1,138 @@ +#ifndef MODELDOCUTILS_H +#define MODELDOCUTILS_H + +#include "destrum/Util/ModelDoc.h" + +#include "spdlog/spdlog.h" + +#include +#include +#include +#include +#include +#include + +namespace ModelDocUtils { + +inline const ModelDoc::MeshPrimitive& GetFirstPrimitiveOrThrow( + const ModelDoc::ModelAsset& model, + const std::string& debugName +) { + if (model.primitives.empty()) { + throw std::runtime_error("ModelDoc loaded no mesh primitives for: " + debugName); + } + + return model.primitives.front(); +} + +inline const ModelDoc::MeshPrimitive& GetFirstSkinnedPrimitiveOrFirstOrThrow( + const ModelDoc::ModelAsset& model, + const std::string& debugName +) { + if (model.primitives.empty()) { + throw std::runtime_error("ModelDoc loaded no mesh primitives for: " + debugName); + } + + const auto it = std::find_if( + model.primitives.begin(), + model.primitives.end(), + [](const ModelDoc::MeshPrimitive& primitive) { + return primitive.skinned; + } + ); + + return it != model.primitives.end() ? *it : model.primitives.front(); +} + +inline glm::vec3 GetImportedBaseColor( + const ModelDoc::ModelAsset& model, + const ModelDoc::MeshPrimitive& primitive, + const glm::vec3& fallback = glm::vec3(1.0f) +) { + if (primitive.materialIndex == ModelDoc::InvalidIndex || + primitive.materialIndex >= model.materials.size()) { + return fallback; + } + + return glm::vec3(model.materials[primitive.materialIndex].baseColor); +} + +inline std::string GetImportedMaterialName( + const ModelDoc::ModelAsset& model, + const ModelDoc::MeshPrimitive& primitive, + const std::string& fallback +) { + if (primitive.materialIndex == ModelDoc::InvalidIndex || + primitive.materialIndex >= model.materials.size()) { + return fallback; + } + + const auto& material = model.materials[primitive.materialIndex]; + return material.name.empty() ? fallback : material.name; +} + +inline std::optional FindFirstTexturePath( + const ModelDoc::ModelAsset& model, + const ModelDoc::MeshPrimitive& primitive, + std::initializer_list preferredSemantics +) { + if (primitive.materialIndex == ModelDoc::InvalidIndex || + primitive.materialIndex >= model.materials.size()) { + return std::nullopt; + } + + const auto& material = model.materials[primitive.materialIndex]; + + for (const auto semantic : preferredSemantics) { + for (const auto& texture : material.textures) { + // gfxDevice.loadImageFromFile currently expects a real file path. + // Embedded GLB textures like "*0" need extraction/support in GfxDevice first. + if (texture.semantic == semantic && !texture.embedded && !texture.path.empty()) { + return texture.path; + } + } + } + + return std::nullopt; +} + +inline std::filesystem::path PickTexturePath( + const ModelDoc::ModelAsset& model, + const ModelDoc::MeshPrimitive& primitive, + const std::filesystem::path& fallbackPath +) { + const auto importedTexturePath = FindFirstTexturePath( + model, + primitive, + { + ModelDoc::TextureSemantic::BaseColor, + ModelDoc::TextureSemantic::Diffuse + } + ); + + if (importedTexturePath.has_value()) { + return std::filesystem::path(*importedTexturePath); + } + spdlog::info("Using fallback path {}", fallbackPath.string()); + return fallbackPath; +} + +inline void LogModelDocSummary( + const ModelDoc::ModelAsset& model, + const std::string& label +) { + spdlog::info( + "Loaded {} | primitives: {} | materials: {} | animations: {} | skinned: {}", + label, + model.primitives.size(), + model.materials.size(), + model.animations.size(), + model.IsSkinned() + ); +} + + + +} // namespace ModelDocUtils + +#endif // MODELDOCUTILS_H diff --git a/destrum/include/destrum/Util/ModelLoader.h b/destrum/include/destrum/Util/ModelLoader.h index d3b6159..fa1b6c6 100644 --- a/destrum/include/destrum/Util/ModelLoader.h +++ b/destrum/include/destrum/Util/ModelLoader.h @@ -768,6 +768,35 @@ static SkinnedModel LoadSkinnedModel(const std::string& path) { return model; } +struct LoadedMesh { + CPUMesh mesh; + std::vector diffuseTexturePaths; +}; + + static std::vector LoadMaterialTexturePaths( + const aiScene* scene, + const aiMesh* mesh, + aiTextureType type, + const std::filesystem::path& modelDir +) { + std::vector paths; + + if (mesh->mMaterialIndex >= scene->mNumMaterials) + return paths; + + aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; + + for (unsigned int i = 0; i < material->GetTextureCount(type); ++i) { + aiString texPath; + if (material->GetTexture(type, i, &texPath) == AI_SUCCESS) { + auto fullPath = modelDir / texPath.C_Str(); + paths.push_back(fullPath.generic_string()); + } + } + + return paths; +} + } // namespace ModelLoader #endif // MODELLOADER_H diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index f03eb4b..16bbb4e 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -5,6 +5,7 @@ #include #include "destrum/Graphics/Resources/Cubemap.h" +#include "destrum/ObjectModel/GameObject.h" class LightKeeper final : public App { public: @@ -28,6 +29,8 @@ private: MaterialID testMaterialID; std::unique_ptr skyboxCubemap; + + GameObject* capybara = nullptr; }; #endif //LIGHTKEEPER_H diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index e56ee3d..f73a0dd 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -10,10 +10,15 @@ #include "destrum/Components/Spinner.h" #include "destrum/Components/OrbitAndSpin.h" #include "destrum/ObjectModel/GameObject.h" -#include "destrum/Util/ModelLoader.h" +#include "destrum/Util/ModelDoc.h" #include "destrum/Components/Animator.h" -LightKeeper::LightKeeper(): App(), renderer(meshCache, materialCache) { +#include +#include + +#include "destrum/Util/ModelDocUtils.h" + +LightKeeper::LightKeeper() : App(), renderer(meshCache, materialCache) { } LightKeeper::~LightKeeper() { @@ -26,23 +31,46 @@ void LightKeeper::customInit() { const float aspectRatio = static_cast(m_params.renderSize.x) / static_cast(m_params.renderSize.y); camera.setAspectRatio(aspectRatio); + ModelDoc::LoadOptions staticModelOptions{}; + staticModelOptions.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode; + staticModelOptions.loadMaterials = true; + staticModelOptions.loadSkeleton = false; + staticModelOptions.loadAnimations = false; + + auto kittyModel = ModelDoc::LoadModel( + AssetFS::GetInstance().GetFullPath("game://kitty.glb").generic_string(), + staticModelOptions + ); + ModelDocUtils::LogModelDocSummary(kittyModel, "kitty.glb"); + + const auto &kittyPrimitive = ModelDocUtils::GetFirstPrimitiveOrThrow(kittyModel, "game://kitty.glb"); + testMesh = kittyPrimitive.mesh; testMesh.name = "Test Mesh"; - auto list_of_models = ModelLoader::LoadGLTF_CPUMeshes_MergedPerMesh(AssetFS::GetInstance().GetFullPath("game://kitty.glb").generic_string()); - testMesh = list_of_models[0]; + testMeshID = meshCache.addMesh(gfxDevice, testMesh); spdlog::info("TestMesh uploaded with id: {}", testMeshID); - auto testimgID = gfxDevice.loadImageFromFile(AssetFS::GetInstance().GetFullPath("game://kitty.png")); - spdlog::info("Test image loaded with id: {}", testimgID); + const auto testTexturePath = ModelDocUtils::PickTexturePath( + kittyModel, + kittyPrimitive, + AssetFS::GetInstance().GetFullPath("game://kitty.png") + ); + + const auto testimgID = gfxDevice.loadImageFromFile(testTexturePath); + spdlog::info("Test image loaded from '{}' with id: {}", testTexturePath.generic_string(), testimgID); + testMaterialID = materialCache.addMaterial(gfxDevice, { - .baseColor = glm::vec3(1.f), + .baseColor = ModelDocUtils::GetImportedBaseColor( + kittyModel, kittyPrimitive), .diffuseTexture = testimgID, + .name = ModelDocUtils::GetImportedMaterialName( + kittyModel, kittyPrimitive, "TestMaterial"), }); spdlog::info("Test material created with id: {}", testMaterialID); camera.SetRotation(glm::radians(glm::vec2(90.f, 0.f))); - auto& scene = SceneManager::GetInstance().CreateScene("Main"); + auto &scene = SceneManager::GetInstance().CreateScene("Main"); // auto testCube = std::make_shared("TestCube"); // auto meshComp = testCube->AddComponent(); @@ -77,8 +105,6 @@ void LightKeeper::customInit() { globeRoot->AddComponent(glm::vec3(0, 1, 0), 1.0f); // spin around Y, rad/sec scene.Add(globeRoot); - - // scene.Add(testCube); // const auto skyboxID = AssetFS::GetInstance().GetFullPath("engine://textures/skybox.jpg"); @@ -97,55 +123,89 @@ void LightKeeper::customInit() { renderer.setSkyboxTexture(skyboxCubemap->GetCubeMapImageID()); - // const auto planeObj = std::make_shared("GroundPlane"); const auto planeMeshComp = planeObj->AddComponent(); - const auto planeModel = ModelLoader::LoadGLTF_CPUMeshes_MergedPerMesh(AssetFS::GetInstance().GetFullPath("game://plane.glb").generic_string()); - const auto planeMeshID = meshCache.addMesh(gfxDevice, planeModel[0]); - const auto planeTextureID = gfxDevice.loadImageFromFile(AssetFS::GetInstance().GetFullPath("game://grass.png")); + auto planeModel = ModelDoc::LoadModel( + AssetFS::GetInstance().GetFullPath("game://plane.glb").generic_string(), + staticModelOptions + ); + ModelDocUtils::LogModelDocSummary(planeModel, "plane.glb"); + + const auto &planePrimitive = ModelDocUtils::GetFirstPrimitiveOrThrow(planeModel, "game://plane.glb"); + const auto planeMeshID = meshCache.addMesh(gfxDevice, planePrimitive.mesh); + + const auto planeTexturePath = ModelDocUtils::PickTexturePath( + planeModel, + planePrimitive, + AssetFS::GetInstance().GetFullPath("game://grass.png") + ); + + const auto planeTextureID = gfxDevice.loadImageFromFile(planeTexturePath); const auto planeMaterialID = materialCache.addMaterial(gfxDevice, { - .baseColor = glm::vec3(1.f), - .textureFilteringMode = TextureFilteringMode::Nearest, - .diffuseTexture = planeTextureID, - .name = "GroundPlaneMaterial", - }); + .baseColor = ModelDocUtils::GetImportedBaseColor( + planeModel, planePrimitive), + .textureFilteringMode = TextureFilteringMode::Nearest, + .diffuseTexture = planeTextureID, + .name = ModelDocUtils::GetImportedMaterialName( + planeModel, planePrimitive, "GroundPlaneMaterial"), + }); + planeMeshComp->SetMeshID(planeMeshID); planeMeshComp->SetMaterialID(planeMaterialID); planeObj->GetTransform().SetWorldPosition(glm::vec3(0.f, -1.0f, 0.f)); planeObj->GetTransform().SetWorldScale(glm::vec3(10.f, 1.f, 10.f)); scene.Add(planeObj); - - // At the bottom of customInit(), replace the incomplete CharObj block: - const auto CharObj = std::make_shared("Character"); + capybara = CharObj.get(); - auto charModel = ModelLoader::LoadSkinnedModel( - AssetFS::GetInstance().GetFullPath("engine://cotw-capybara-male/source/capybara.fbx").generic_string() + ModelDoc::LoadOptions characterModelOptions{}; + characterModelOptions.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode; + characterModelOptions.loadMaterials = true; + characterModelOptions.loadSkeleton = true; + characterModelOptions.loadAnimations = true; + + auto charModel = ModelDoc::LoadModel( + AssetFS::GetInstance().GetFullPath("engine://cotw-capybara-male/source/capybara.fbx").generic_string(), + characterModelOptions + ); + ModelDocUtils::LogModelDocSummary(charModel, "capybara.fbx"); + + const auto &charPrimitive = ModelDocUtils::GetFirstSkinnedPrimitiveOrFirstOrThrow( + charModel, + "engine://cotw-capybara-male/source/capybara.fbx" ); - const auto charMeshID = meshCache.addMesh(gfxDevice, charModel.meshes[0]); + const auto charMeshID = meshCache.addMesh(gfxDevice, charPrimitive.mesh); - const auto charTextureID = gfxDevice.loadImageFromFile(AssetFS::GetInstance().GetFullPath("engine://cotw-capybara-male/textures/capybara_male_light_brown_dif.ddsc.DECA.RE.png")); + const auto charTexturePath = ModelDocUtils::PickTexturePath( + charModel, + charPrimitive, + AssetFS::GetInstance().GetFullPath( + "engine://cotw-capybara-male/textures/capybara_male_light_brown_dif.ddsc.DECA.RE.pngballs") + ); + + const auto charTextureID = gfxDevice.loadImageFromFile(charTexturePath); // const auto charTextureID = gfxDevice.loadImageFromFile(AssetFS::GetInstance().GetFullPath("engine://char.jpg")); const auto charMaterialID = materialCache.addMaterial(gfxDevice, { - .baseColor = glm::vec3(1.f), - .diffuseTexture = charTextureID, - .name = "CharacterMaterial", - }); + .baseColor = ModelDocUtils::GetImportedBaseColor( + charModel, charPrimitive), + .diffuseTexture = charTextureID, + .name = ModelDocUtils::GetImportedMaterialName( + charModel, charPrimitive, "CharacterMaterial"), + }); const auto charMeshComp = CharObj->AddComponent(); charMeshComp->SetMeshID(charMeshID); charMeshComp->SetMaterialID(charMaterialID); - const auto animator = CharObj->AddComponent(); animator->setSkeleton(std::move(charModel.skeleton)); std::string firstAnimationName; - for (auto& clip : charModel.animations) { + for (auto &clip: charModel.animations) { spdlog::info("Loaded animation: '{}' ({:.2f}s)", clip.name, clip.duration); if (firstAnimationName.empty()) @@ -159,11 +219,132 @@ void LightKeeper::customInit() { animator->play(firstAnimationName); } - animator->play("capybara_canter_fwd_01|capybara_walk_fwd_01"); + animator->play("capybara_canter_fwd_01|capybara_canter_fwd_01|run"); CharObj->GetTransform().SetWorldPosition(glm::vec3(0.f, 0.f, 0.f)); // CharObj->GetTransform().SetWorldScale(0.01f, 0.01f, 0.01f); scene.Add(CharObj); + + ModelDoc::LoadOptions options{}; + options.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode; + + auto model = ModelDoc::LoadModel( + AssetFS::GetInstance() + .GetFullPath("engine://multiple_object_test.fbx") + .generic_string(), + options + ); + + ModelDocUtils::LogModelDocSummary(model, "engine://multiple_object_test.fbx"); + + auto root = std::make_shared("MultiMeshModel"); + root->GetTransform().SetWorldPosition(glm::vec3(0.1f, 0.1f, 0.1f)); + scene.Add(root); + + for (std::size_t i = 0; i < model.primitives.size(); ++i) { + const auto &primitive = model.primitives[i]; + + const auto meshID = meshCache.addMesh(gfxDevice, primitive.mesh); + + const auto texturePath = ModelDocUtils::PickTexturePath( + model, + primitive, + AssetFS::GetInstance().GetFullPath("engine://textures/white.png") + ); + + const auto textureID = gfxDevice.loadImageFromFile(texturePath); + + const auto materialID = materialCache.addMaterial(gfxDevice, { + .baseColor = ModelDocUtils::GetImportedBaseColor( + model, primitive), + .diffuseTexture = textureID, + .name = ModelDocUtils::GetImportedMaterialName( + model, + primitive, + primitive.mesh.name + "_Material" + ), + }); + + auto part = std::make_shared( + primitive.mesh.name.empty() + ? "Primitive_" + std::to_string(i) + : primitive.mesh.name + ); + + auto meshComp = part->AddComponent(); + meshComp->SetMeshID(meshID); + meshComp->SetMaterialID(materialID); + + scene.Add(part); + } + + + { + const auto CharObj = std::make_shared("Character"); + + ModelDoc::LoadOptions characterOptions{}; + characterOptions.meshImportMode = ModelDoc::MeshImportMode::MergedPerNode; + characterOptions.loadMaterials = true; + characterOptions.loadSkeleton = true; + characterOptions.loadAnimations = false; + + auto charModel = ModelDoc::LoadModel( + AssetFS::GetInstance() + .GetFullPath("engine://characterMedium.fbx") + .generic_string(), + characterOptions + ); + + const auto &charPrimitive = + ModelDocUtils::GetFirstSkinnedPrimitiveOrFirstOrThrow( + charModel, + "engine://characterMedium.fbx" + ); + + const auto charMeshID = meshCache.addMesh(gfxDevice, charPrimitive.mesh); + + const auto charTextureID = gfxDevice.loadImageFromFile( + AssetFS::GetInstance().GetFullPath("engine://textures/criminalMaleA.png") + ); + + const auto charMaterialID = materialCache.addMaterial(gfxDevice, { + .baseColor = ModelDocUtils::GetImportedBaseColor( + charModel, charPrimitive), + .diffuseTexture = charTextureID, + .name = ModelDocUtils::GetImportedMaterialName( + charModel, + charPrimitive, + "CharacterMaterial" + ), + }); + + const auto charMeshComp = CharObj->AddComponent(); + charMeshComp->SetMeshID(charMeshID); + charMeshComp->SetMaterialID(charMaterialID); + + const auto animator = CharObj->AddComponent(); + animator->setSkeleton(charModel.skeleton); + + auto runClips = ModelDoc::LoadAnimationClips( + AssetFS::GetInstance() + .GetFullPath("engine://run.fbx") + .generic_string(), + charModel.skeleton + ); + + for (auto &clip: runClips) { + spdlog::info("Loaded animation: '{}' ({:.2f}s)", clip.name, clip.duration); + animator->addClip(std::make_shared(std::move(clip))); + } + + if (!runClips.empty()) { + animator->play("Root|Run"); + } + + CharObj->GetTransform().SetWorldPosition(glm::vec3(0.f)); + CharObj->GetTransform().SetWorldPosition(glm::vec3(5, 0, 0)); + scene.Add(CharObj); + } } void LightKeeper::customUpdate(float dt) { @@ -194,7 +375,7 @@ void LightKeeper::customDraw() { renderer.endDrawing(); const auto cmd = gfxDevice.beginFrame(); - const auto& drawImage = renderer.getDrawImage(gfxDevice); + const auto &drawImage = renderer.getDrawImage(gfxDevice); renderer.draw( cmd, gfxDevice, camera, GameRenderer::SceneData{