From b736c6b2e1d0a849a735e8a9a1351334b76d15ab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 01:07:00 -0700 Subject: [PATCH] feat(wom): add WOM3 multi-batch format for material-aware models WOM1/WOM2 had a single mesh with one texture, which lost the multi-submesh structure of complex M2 models (body+hair+eyes+armor each need different textures and blend modes). WOM3 adds a Batch array: each batch has indexStart/indexCount + a textureIndex into texturePaths + blendMode + flags. Loader is fully backward compatible: WOM1/WOM2 files still load, and WOM3 with no batches block falls back to a single full-mesh batch. fromM2 now extracts batches with materials, and toM2 emits matching M2 batches so the renderer can draw them correctly. --- include/pipeline/wowee_model.hpp | 17 ++++- src/pipeline/wowee_model.cpp | 124 +++++++++++++++++++++++++------ tools/editor/FORMAT_SPEC.md | 13 ++-- 3 files changed, 124 insertions(+), 30 deletions(-) diff --git a/include/pipeline/wowee_model.hpp b/include/pipeline/wowee_model.hpp index 8e44870f..a0bba030 100644 --- a/include/pipeline/wowee_model.hpp +++ b/include/pipeline/wowee_model.hpp @@ -12,7 +12,7 @@ namespace pipeline { struct M2Model; // Wowee Open Model format (.wom) — novel format, no Blizzard IP -// WOM1: static geometry | WOM2: + bones + animations +// WOM1: static geometry | WOM2: + bones + animations | WOM3: + multi-batch materials struct WoweeModel { struct Vertex { glm::vec3 position; @@ -43,18 +43,31 @@ struct WoweeModel { std::vector> boneKeyframes; // [boneIdx][keyframe] }; + // WOM3: a contiguous slice of indices that draws with one material/texture. + // Most M2 models have multiple submeshes (body, hair, eyes, etc.) — each + // becomes one Batch in WOM3. + struct Batch { + uint32_t indexStart = 0; // first index in the global index buffer + uint32_t indexCount = 0; // number of indices to draw + uint32_t textureIndex = 0; // index into texturePaths + uint16_t blendMode = 0; // 0=opaque, 1=alpha-test, 2=alpha, 3=add + uint16_t flags = 0; // bit 0 = unlit, bit 1 = two-sided, bit 2 = no z-write + }; + std::string name; std::vector vertices; std::vector indices; std::vector texturePaths; std::vector bones; std::vector animations; + std::vector batches; // empty in WOM1/WOM2, populated in WOM3 float boundRadius = 1.0f; glm::vec3 boundMin{0}, boundMax{0}; - uint32_t version = 1; // 1=WOM1(static), 2=WOM2(animated) + uint32_t version = 1; // 1=WOM1(static), 2=WOM2(animated), 3=WOM3(multi-batch) bool isValid() const { return !vertices.empty() && !indices.empty(); } bool hasAnimation() const { return !bones.empty() && !animations.empty(); } + bool hasBatches() const { return !batches.empty(); } }; class WoweeModelLoader { diff --git a/src/pipeline/wowee_model.cpp b/src/pipeline/wowee_model.cpp index d444a826..be8d9b58 100644 --- a/src/pipeline/wowee_model.cpp +++ b/src/pipeline/wowee_model.cpp @@ -11,6 +11,7 @@ namespace pipeline { static constexpr uint32_t WOM_MAGIC = 0x314D4F57; // "WOM1" static constexpr uint32_t WOM2_MAGIC = 0x324D4F57; // "WOM2" +static constexpr uint32_t WOM3_MAGIC = 0x334D4F57; // "WOM3" bool WoweeModelLoader::exists(const std::string& basePath) { return std::filesystem::exists(basePath + ".wom"); @@ -25,9 +26,10 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) { uint32_t magic; f.read(reinterpret_cast(&magic), 4); - bool isV2 = (magic == WOM2_MAGIC); - if (magic != WOM_MAGIC && magic != WOM2_MAGIC) return model; - model.version = isV2 ? 2 : 1; + bool isV2 = (magic == WOM2_MAGIC || magic == WOM3_MAGIC); + bool isV3 = (magic == WOM3_MAGIC); + if (magic != WOM_MAGIC && magic != WOM2_MAGIC && magic != WOM3_MAGIC) return model; + model.version = isV3 ? 3 : (isV2 ? 2 : 1); uint32_t vertCount, indexCount, texCount; f.read(reinterpret_cast(&vertCount), 4); @@ -110,8 +112,25 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) { } } - LOG_INFO("WOM", (isV2 ? "2" : "1"), " loaded: ", basePath, " (", vertCount, " verts, ", - model.bones.size(), " bones, ", model.animations.size(), " anims)"); + // WOM3: read batches (multi-material support) + if (isV3) { + uint32_t batchCount = 0; + if (f.read(reinterpret_cast(&batchCount), 4) && batchCount > 0 && batchCount <= 4096) { + model.batches.resize(batchCount); + for (uint32_t i = 0; i < batchCount; i++) { + auto& b = model.batches[i]; + f.read(reinterpret_cast(&b.indexStart), 4); + f.read(reinterpret_cast(&b.indexCount), 4); + f.read(reinterpret_cast(&b.textureIndex), 4); + f.read(reinterpret_cast(&b.blendMode), 2); + f.read(reinterpret_cast(&b.flags), 2); + } + } + } + + LOG_INFO("WOM", (isV3 ? "3" : (isV2 ? "2" : "1")), " loaded: ", basePath, " (", + vertCount, " verts, ", model.bones.size(), " bones, ", + model.animations.size(), " anims, ", model.batches.size(), " batches)"); return model; } @@ -124,7 +143,13 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath if (!f) return false; bool hasAnim = model.hasAnimation(); - uint32_t magic = hasAnim ? WOM2_MAGIC : WOM_MAGIC; + bool hasBatches = model.hasBatches(); + // WOM3 implies WOM2 layout (vertex format with bones), so we only emit + // WOM3 if the model also has animation data — pure-batch static meshes + // still go to WOM1/WOM2 with batches written via the WOM3 trailing block + // when present alongside animation. For static-only with batches, write + // as WOM3 anyway (decoder handles missing bones). + uint32_t magic = hasBatches ? WOM3_MAGIC : (hasAnim ? WOM2_MAGIC : WOM_MAGIC); f.write(reinterpret_cast(&magic), 4); uint32_t vertCount = static_cast(model.vertices.size()); @@ -141,8 +166,8 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath f.write(reinterpret_cast(&nameLen), 2); f.write(model.name.data(), nameLen); - // WOM2 writes full vertex with bone data; WOM1 writes 32-byte vertex - if (hasAnim) { + // WOM2/WOM3 write full vertex with bone data; WOM1 writes 32-byte vertex + if (hasAnim || hasBatches) { f.write(reinterpret_cast(model.vertices.data()), vertCount * sizeof(WoweeModel::Vertex)); } else { @@ -161,8 +186,8 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath f.write(path.data(), pathLen); } - // WOM2: write bones and animations - if (hasAnim) { + // WOM2/WOM3: write bones and animations (always, even if empty for WOM3) + if (hasAnim || hasBatches) { uint32_t boneCount = static_cast(model.bones.size()); f.write(reinterpret_cast(&boneCount), 4); for (const auto& bone : model.bones) { @@ -194,8 +219,22 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath } } - LOG_INFO("WOM", (hasAnim ? "2" : "1"), " saved: ", womPath, " (", vertCount, " verts, ", - model.bones.size(), " bones, ", model.animations.size(), " anims)"); + // WOM3: write batches + if (hasBatches) { + uint32_t batchCount = static_cast(model.batches.size()); + f.write(reinterpret_cast(&batchCount), 4); + for (const auto& b : model.batches) { + f.write(reinterpret_cast(&b.indexStart), 4); + f.write(reinterpret_cast(&b.indexCount), 4); + f.write(reinterpret_cast(&b.textureIndex), 4); + f.write(reinterpret_cast(&b.blendMode), 2); + f.write(reinterpret_cast(&b.flags), 2); + } + } + + LOG_INFO("WOM", (hasBatches ? "3" : (hasAnim ? "2" : "1")), " saved: ", womPath, + " (", vertCount, " verts, ", model.bones.size(), " bones, ", + model.animations.size(), " anims, ", model.batches.size(), " batches)"); return true; } @@ -260,6 +299,25 @@ WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) model.bones.push_back(wb); } + // Convert batches with material/blend mode info (WOM3 feature). + // Each M2 batch maps to a WOM batch — preserves multi-submesh material structure. + for (const auto& mb : m2.batches) { + WoweeModel::Batch wb; + wb.indexStart = mb.indexStart; + wb.indexCount = mb.indexCount; + // Resolve textureLookup -> texture index + uint32_t lookupIdx = mb.textureIndex; + wb.textureIndex = (lookupIdx < m2.textureLookup.size()) + ? static_cast(std::max(0, m2.textureLookup[lookupIdx])) + : 0; + if (mb.materialIndex < m2.materials.size()) { + const auto& mat = m2.materials[mb.materialIndex]; + wb.blendMode = mat.blendMode; + wb.flags = mat.flags; + } + model.batches.push_back(wb); + } + // Convert animations (first keyframe per bone per sequence) for (const auto& seq : m2.sequences) { WoweeModel::Animation anim; @@ -353,18 +411,38 @@ M2Model WoweeModelLoader::toM2(const WoweeModel& wom) { tex.filename = tp; m.textures.push_back(tex); } - m.textureLookup = {0}; + m.textureLookup.clear(); + for (uint32_t i = 0; i < wom.texturePaths.size(); i++) + m.textureLookup.push_back(static_cast(i)); + if (m.textureLookup.empty()) m.textureLookup.push_back(0); - M2Batch batch{}; - batch.textureCount = std::min(1u, static_cast(wom.texturePaths.size())); - batch.indexCount = static_cast(m.indices.size()); - batch.vertexCount = static_cast(m.vertices.size()); - m.batches.push_back(batch); - - M2Material mat; - mat.flags = 0; - mat.blendMode = 0; - m.materials.push_back(mat); + if (wom.hasBatches()) { + for (const auto& wb : wom.batches) { + M2Batch batch{}; + batch.indexStart = wb.indexStart; + batch.indexCount = wb.indexCount; + batch.vertexCount = static_cast(m.vertices.size()); + batch.textureCount = 1; + batch.textureIndex = static_cast( + std::min(wb.textureIndex, m.textureLookup.size() - 1)); + batch.materialIndex = static_cast(m.materials.size()); + m.batches.push_back(batch); + M2Material mat; + mat.flags = wb.flags; + mat.blendMode = wb.blendMode; + m.materials.push_back(mat); + } + } else { + M2Batch batch{}; + batch.textureCount = std::min(1u, static_cast(wom.texturePaths.size())); + batch.indexCount = static_cast(m.indices.size()); + batch.vertexCount = static_cast(m.vertices.size()); + m.batches.push_back(batch); + M2Material mat; + mat.flags = 0; + mat.blendMode = 0; + m.materials.push_back(mat); + } return m; } diff --git a/tools/editor/FORMAT_SPEC.md b/tools/editor/FORMAT_SPEC.md index 01f54787..277e7c00 100644 --- a/tools/editor/FORMAT_SPEC.md +++ b/tools/editor/FORMAT_SPEC.md @@ -20,14 +20,17 @@ Novel file formats for custom WoW zone content. No Blizzard IP. ## WOM — Wowee Open Model (binary) - Extension: `.wom` -- Magic: `WOM1` (0x314D4F57) for static, `WOM2` (0x324D4F57) for animated +- Magic: `WOM1` (0x314D4F57) static, `WOM2` (0x324D4F57) animated, `WOM3` (0x334D4F57) multi-batch - Layout: magic(4) + vertCount(4) + indexCount(4) + texCount(4) + bounds(28) + name + vertices + indices + texPaths - 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 +- WOM2/WOM3 Vertex: + boneWeights(4) + boneIndices(4) = 40 bytes +- WOM2/WOM3 Bones: boneCount(4) + [keyBoneId(4) + parentBone(2) + pivot(12) + flags(4)] × N +- WOM2/WOM3 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 +- WOM3 Batches: batchCount(4) + [indexStart(4) + indexCount(4) + textureIndex(4) + blendMode(2) + flags(2)] × N +- WOM3 blendMode: 0=opaque, 1=alpha-test, 2=alpha, 3=add, 4=mod, 5=mod2x, 6=blendAdd, 7=screen +- WOM3 flags: bit 0 = unlit, bit 1 = two-sided, bit 2 = no z-write +- Backward compatible: WOM1 files load without bone/animation data; WOM3 falls back to single-batch when batches block missing ## WOB — Wowee Open Building (binary) - Extension: `.wob`