diff --git a/include/pipeline/wowee_model.hpp b/include/pipeline/wowee_model.hpp index 6f0144db..6a050812 100644 --- a/include/pipeline/wowee_model.hpp +++ b/include/pipeline/wowee_model.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,22 +10,49 @@ namespace wowee { namespace pipeline { // Wowee Open Model format (.wom) — novel format, no Blizzard IP -// Designed for static doodads, props, and simple animated objects +// WOM1: static geometry | WOM2: + bones + animations struct WoweeModel { struct Vertex { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; + uint8_t boneWeights[4] = {255, 0, 0, 0}; + uint8_t boneIndices[4] = {0, 0, 0, 0}; + }; + + struct Bone { + int32_t keyBoneId = -1; + int16_t parentBone = -1; + glm::vec3 pivot{0}; + uint32_t flags = 0; + }; + + struct AnimKeyframe { + uint32_t timeMs; + glm::vec3 translation; + glm::quat rotation; + glm::vec3 scale; + }; + + struct Animation { + uint32_t id = 0; + uint32_t durationMs = 0; + float movingSpeed = 0; + std::vector> boneKeyframes; // [boneIdx][keyframe] }; std::string name; std::vector vertices; std::vector indices; - std::vector texturePaths; // PNG paths + std::vector texturePaths; + std::vector bones; + std::vector animations; float boundRadius = 1.0f; glm::vec3 boundMin{0}, boundMax{0}; + uint32_t version = 1; // 1=WOM1(static), 2=WOM2(animated) bool isValid() const { return !vertices.empty() && !indices.empty(); } + bool hasAnimation() const { return !bones.empty() && !animations.empty(); } }; class WoweeModelLoader { diff --git a/src/pipeline/wowee_model.cpp b/src/pipeline/wowee_model.cpp index 57fcf84b..1497224f 100644 --- a/src/pipeline/wowee_model.cpp +++ b/src/pipeline/wowee_model.cpp @@ -10,6 +10,7 @@ namespace wowee { namespace pipeline { static constexpr uint32_t WOM_MAGIC = 0x314D4F57; // "WOM1" +static constexpr uint32_t WOM2_MAGIC = 0x324D4F57; // "WOM2" bool WoweeModelLoader::exists(const std::string& basePath) { return std::filesystem::exists(basePath + ".wom"); @@ -24,7 +25,9 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) { uint32_t magic; f.read(reinterpret_cast(&magic), 4); - if (magic != WOM_MAGIC) return model; + bool isV2 = (magic == WOM2_MAGIC); + if (magic != WOM_MAGIC && magic != WOM2_MAGIC) return model; + model.version = isV2 ? 2 : 1; uint32_t vertCount, indexCount, texCount; f.read(reinterpret_cast(&vertCount), 4); @@ -34,23 +37,31 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) { f.read(reinterpret_cast(&model.boundMin), 12); f.read(reinterpret_cast(&model.boundMax), 12); - // Read name uint16_t nameLen; f.read(reinterpret_cast(&nameLen), 2); model.name.resize(nameLen); f.read(model.name.data(), nameLen); - // Read vertices + // Read vertices (WOM1: 32 bytes/vert, WOM2: 40 bytes with bone data) model.vertices.resize(vertCount); - f.read(reinterpret_cast(model.vertices.data()), - vertCount * sizeof(WoweeModel::Vertex)); + if (isV2) { + f.read(reinterpret_cast(model.vertices.data()), + vertCount * sizeof(WoweeModel::Vertex)); + } else { + // WOM1 backward compat: read 32-byte vertices (no bone data) + struct V1Vertex { glm::vec3 pos; glm::vec3 norm; glm::vec2 uv; }; + for (uint32_t i = 0; i < vertCount; i++) { + V1Vertex v1; + f.read(reinterpret_cast(&v1), sizeof(V1Vertex)); + model.vertices[i].position = v1.pos; + model.vertices[i].normal = v1.norm; + model.vertices[i].texCoord = v1.uv; + } + } - // Read indices model.indices.resize(indexCount); - f.read(reinterpret_cast(model.indices.data()), - indexCount * sizeof(uint32_t)); + f.read(reinterpret_cast(model.indices.data()), indexCount * 4); - // Read texture paths for (uint32_t i = 0; i < texCount; i++) { uint16_t pathLen; f.read(reinterpret_cast(&pathLen), 2); @@ -59,8 +70,48 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) { model.texturePaths.push_back(path); } - LOG_INFO("WOM loaded: ", basePath, " (", vertCount, " verts, ", - indexCount / 3, " tris)"); + // WOM2: read bones and animations + if (isV2) { + uint32_t boneCount = 0; + if (f.read(reinterpret_cast(&boneCount), 4) && boneCount > 0 && boneCount <= 512) { + model.bones.resize(boneCount); + for (uint32_t bi = 0; bi < boneCount; bi++) { + auto& bone = model.bones[bi]; + f.read(reinterpret_cast(&bone.keyBoneId), 4); + f.read(reinterpret_cast(&bone.parentBone), 2); + f.read(reinterpret_cast(&bone.pivot), 12); + f.read(reinterpret_cast(&bone.flags), 4); + } + } + + uint32_t animCount = 0; + if (f.read(reinterpret_cast(&animCount), 4) && animCount > 0 && animCount <= 1024) { + model.animations.resize(animCount); + for (uint32_t ai = 0; ai < animCount; ai++) { + auto& anim = model.animations[ai]; + f.read(reinterpret_cast(&anim.id), 4); + f.read(reinterpret_cast(&anim.durationMs), 4); + f.read(reinterpret_cast(&anim.movingSpeed), 4); + + anim.boneKeyframes.resize(model.bones.size()); + for (size_t bi = 0; bi < model.bones.size(); bi++) { + uint32_t kfCount = 0; + f.read(reinterpret_cast(&kfCount), 4); + for (uint32_t ki = 0; ki < kfCount && ki < 10000; ki++) { + WoweeModel::AnimKeyframe kf; + f.read(reinterpret_cast(&kf.timeMs), 4); + f.read(reinterpret_cast(&kf.translation), 12); + f.read(reinterpret_cast(&kf.rotation), 16); + f.read(reinterpret_cast(&kf.scale), 12); + anim.boneKeyframes[bi].push_back(kf); + } + } + } + } + } + + LOG_INFO("WOM", (isV2 ? "2" : "1"), " loaded: ", basePath, " (", vertCount, " verts, ", + model.bones.size(), " bones, ", model.animations.size(), " anims)"); return model; } @@ -72,7 +123,10 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath std::ofstream f(womPath, std::ios::binary); if (!f) return false; - f.write(reinterpret_cast(&WOM_MAGIC), 4); + bool hasAnim = model.hasAnimation(); + uint32_t magic = hasAnim ? WOM2_MAGIC : WOM_MAGIC; + f.write(reinterpret_cast(&magic), 4); + uint32_t vertCount = static_cast(model.vertices.size()); uint32_t indexCount = static_cast(model.indices.size()); uint32_t texCount = static_cast(model.texturePaths.size()); @@ -87,10 +141,19 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath f.write(reinterpret_cast(&nameLen), 2); f.write(model.name.data(), nameLen); - f.write(reinterpret_cast(model.vertices.data()), - vertCount * sizeof(WoweeModel::Vertex)); - f.write(reinterpret_cast(model.indices.data()), - indexCount * sizeof(uint32_t)); + // WOM2 writes full vertex with bone data; WOM1 writes 32-byte vertex + if (hasAnim) { + f.write(reinterpret_cast(model.vertices.data()), + vertCount * sizeof(WoweeModel::Vertex)); + } else { + for (const auto& v : model.vertices) { + f.write(reinterpret_cast(&v.position), 12); + f.write(reinterpret_cast(&v.normal), 12); + f.write(reinterpret_cast(&v.texCoord), 8); + } + } + + f.write(reinterpret_cast(model.indices.data()), indexCount * 4); for (const auto& path : model.texturePaths) { uint16_t pathLen = static_cast(path.size()); @@ -98,8 +161,41 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath f.write(path.data(), pathLen); } - LOG_INFO("WOM saved: ", womPath, " (", vertCount, " verts, ", - indexCount / 3, " tris)"); + // WOM2: write bones and animations + if (hasAnim) { + uint32_t boneCount = static_cast(model.bones.size()); + f.write(reinterpret_cast(&boneCount), 4); + for (const auto& bone : model.bones) { + f.write(reinterpret_cast(&bone.keyBoneId), 4); + f.write(reinterpret_cast(&bone.parentBone), 2); + f.write(reinterpret_cast(&bone.pivot), 12); + f.write(reinterpret_cast(&bone.flags), 4); + } + + uint32_t animCount = static_cast(model.animations.size()); + f.write(reinterpret_cast(&animCount), 4); + for (const auto& anim : model.animations) { + f.write(reinterpret_cast(&anim.id), 4); + f.write(reinterpret_cast(&anim.durationMs), 4); + f.write(reinterpret_cast(&anim.movingSpeed), 4); + + for (size_t bi = 0; bi < model.bones.size(); bi++) { + uint32_t kfCount = (bi < anim.boneKeyframes.size()) + ? static_cast(anim.boneKeyframes[bi].size()) : 0; + f.write(reinterpret_cast(&kfCount), 4); + for (uint32_t ki = 0; ki < kfCount; ki++) { + const auto& kf = anim.boneKeyframes[bi][ki]; + f.write(reinterpret_cast(&kf.timeMs), 4); + f.write(reinterpret_cast(&kf.translation), 12); + f.write(reinterpret_cast(&kf.rotation), 16); + f.write(reinterpret_cast(&kf.scale), 12); + } + } + } + } + + LOG_INFO("WOM", (hasAnim ? "2" : "1"), " saved: ", womPath, " (", vertCount, " verts, ", + model.bones.size(), " bones, ", model.animations.size(), " anims)"); return true; } @@ -112,7 +208,6 @@ WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) auto m2 = M2Loader::load(data); - // Load skin file for WotLK M2s if (!m2.isValid()) { std::string skinPath = m2Path; auto dotPos = skinPath.rfind('.'); @@ -128,25 +223,25 @@ WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) model.name = m2.name; model.boundRadius = m2.boundRadius; - // Convert M2 vertices to WOM format (strip bone data) + // Convert vertices with bone data model.vertices.reserve(m2.vertices.size()); for (const auto& v : m2.vertices) { WoweeModel::Vertex wv; wv.position = v.position; wv.normal = v.normal; wv.texCoord = v.texCoords[0]; + std::memcpy(wv.boneWeights, v.boneWeights, 4); + std::memcpy(wv.boneIndices, v.boneIndices, 4); model.vertices.push_back(wv); model.boundMin = glm::min(model.boundMin, v.position); model.boundMax = glm::max(model.boundMax, v.position); } - // Convert indices (M2 uses uint16, WOM uses uint32) model.indices.reserve(m2.indices.size()); for (uint16_t idx : m2.indices) model.indices.push_back(static_cast(idx)); - // Convert texture paths (BLP → PNG) for (const auto& tex : m2.textures) { std::string path = tex.filename; auto dot = path.rfind('.'); @@ -155,6 +250,77 @@ WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) model.texturePaths.push_back(path); } + // Convert bones + for (const auto& b : m2.bones) { + WoweeModel::Bone wb; + wb.keyBoneId = b.keyBoneId; + wb.parentBone = b.parentBone; + wb.pivot = b.pivot; + wb.flags = b.flags; + model.bones.push_back(wb); + } + + // Convert animations (first keyframe per bone per sequence) + for (const auto& seq : m2.sequences) { + WoweeModel::Animation anim; + anim.id = seq.id; + anim.durationMs = seq.duration; + anim.movingSpeed = seq.movingSpeed; + anim.boneKeyframes.resize(model.bones.size()); + + for (size_t bi = 0; bi < m2.bones.size() && bi < model.bones.size(); bi++) { + const auto& bone = m2.bones[bi]; + // Find keyframes for this sequence index + uint32_t seqIdx = static_cast(&seq - m2.sequences.data()); + + auto extractKeys = [&](const M2AnimationTrack& track, size_t boneIdx) { + if (seqIdx >= track.sequences.size()) return; + const auto& sk = track.sequences[seqIdx]; + for (size_t ki = 0; ki < sk.timestamps.size(); ki++) { + // Check if we already have this timestamp + bool found = false; + for (auto& existing : anim.boneKeyframes[boneIdx]) { + if (existing.timeMs == sk.timestamps[ki]) { + if (!sk.vec3Values.empty() && ki < sk.vec3Values.size()) { + if (&track == &bone.translation) + existing.translation = sk.vec3Values[ki]; + else + existing.scale = sk.vec3Values[ki]; + } + if (!sk.quatValues.empty() && ki < sk.quatValues.size()) + existing.rotation = sk.quatValues[ki]; + found = true; + break; + } + } + if (!found) { + WoweeModel::AnimKeyframe kf; + kf.timeMs = sk.timestamps[ki]; + kf.translation = glm::vec3(0); + kf.rotation = glm::quat(1, 0, 0, 0); + kf.scale = glm::vec3(1); + if (!sk.vec3Values.empty() && ki < sk.vec3Values.size()) { + if (&track == &bone.translation) + kf.translation = sk.vec3Values[ki]; + else + kf.scale = sk.vec3Values[ki]; + } + if (!sk.quatValues.empty() && ki < sk.quatValues.size()) + kf.rotation = sk.quatValues[ki]; + anim.boneKeyframes[boneIdx].push_back(kf); + } + } + }; + + extractKeys(bone.translation, bi); + extractKeys(bone.rotation, bi); + extractKeys(bone.scale, bi); + } + + if (anim.durationMs > 0) model.animations.push_back(anim); + } + + model.version = model.hasAnimation() ? 2 : 1; return model; } diff --git a/tools/editor/FORMAT_SPEC.md b/tools/editor/FORMAT_SPEC.md index 207d360c..01f54787 100644 --- a/tools/editor/FORMAT_SPEC.md +++ b/tools/editor/FORMAT_SPEC.md @@ -20,10 +20,14 @@ Novel file formats for custom WoW zone content. No Blizzard IP. ## WOM — Wowee Open Model (binary) - Extension: `.wom` -- Magic: `WOM1` (0x314D4F57) +- Magic: `WOM1` (0x314D4F57) for static, `WOM2` (0x324D4F57) for animated - Layout: magic(4) + vertCount(4) + indexCount(4) + texCount(4) + bounds(28) + name + vertices + indices + texPaths -- Vertex: position(vec3) + normal(vec3) + texCoord(vec2) = 32 bytes -- Note: geometry-only (no skeletal animation — WOM2 planned for bone data) +- WOM1 Vertex: position(vec3) + normal(vec3) + texCoord(vec2) = 32 bytes +- WOM2 Vertex: + boneWeights(4) + boneIndices(4) = 40 bytes +- WOM2 Bones: boneCount(4) + [keyBoneId(4) + parentBone(2) + pivot(12) + flags(4)] × N +- WOM2 Animations: animCount(4) + [id(4) + duration(4) + speed(4) + per-bone keyframes] × N +- Keyframe: timeMs(4) + translation(12) + rotation(16) + scale(12) = 44 bytes +- Backward compatible: WOM1 files load without bone/animation data ## WOB — Wowee Open Building (binary) - Extension: `.wob`