Kelsidavis-WoWee/src/pipeline/wowee_model.cpp
Kelsi b736c6b2e1 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.
2026-05-06 01:07:00 -07:00

466 lines
19 KiB
C++

#include "pipeline/wowee_model.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/m2_loader.hpp"
#include "core/logger.hpp"
#include <fstream>
#include <filesystem>
#include <cstring>
namespace wowee {
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");
}
WoweeModel WoweeModelLoader::load(const std::string& basePath) {
WoweeModel model;
std::string womPath = basePath + ".wom";
std::ifstream f(womPath, std::ios::binary);
if (!f) return model;
uint32_t magic;
f.read(reinterpret_cast<char*>(&magic), 4);
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<char*>(&vertCount), 4);
f.read(reinterpret_cast<char*>(&indexCount), 4);
f.read(reinterpret_cast<char*>(&texCount), 4);
f.read(reinterpret_cast<char*>(&model.boundRadius), 4);
f.read(reinterpret_cast<char*>(&model.boundMin), 12);
f.read(reinterpret_cast<char*>(&model.boundMax), 12);
uint16_t nameLen;
f.read(reinterpret_cast<char*>(&nameLen), 2);
model.name.resize(nameLen);
f.read(model.name.data(), nameLen);
// Read vertices (WOM1: 32 bytes/vert, WOM2: 40 bytes with bone data)
model.vertices.resize(vertCount);
if (isV2) {
f.read(reinterpret_cast<char*>(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<char*>(&v1), sizeof(V1Vertex));
model.vertices[i].position = v1.pos;
model.vertices[i].normal = v1.norm;
model.vertices[i].texCoord = v1.uv;
}
}
model.indices.resize(indexCount);
f.read(reinterpret_cast<char*>(model.indices.data()), indexCount * 4);
for (uint32_t i = 0; i < texCount; i++) {
uint16_t pathLen;
f.read(reinterpret_cast<char*>(&pathLen), 2);
std::string path(pathLen, '\0');
f.read(path.data(), pathLen);
model.texturePaths.push_back(path);
}
// WOM2: read bones and animations
if (isV2) {
uint32_t boneCount = 0;
if (f.read(reinterpret_cast<char*>(&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<char*>(&bone.keyBoneId), 4);
f.read(reinterpret_cast<char*>(&bone.parentBone), 2);
f.read(reinterpret_cast<char*>(&bone.pivot), 12);
f.read(reinterpret_cast<char*>(&bone.flags), 4);
}
}
uint32_t animCount = 0;
if (f.read(reinterpret_cast<char*>(&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<char*>(&anim.id), 4);
f.read(reinterpret_cast<char*>(&anim.durationMs), 4);
f.read(reinterpret_cast<char*>(&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<char*>(&kfCount), 4);
for (uint32_t ki = 0; ki < kfCount && ki < 10000; ki++) {
WoweeModel::AnimKeyframe kf;
f.read(reinterpret_cast<char*>(&kf.timeMs), 4);
f.read(reinterpret_cast<char*>(&kf.translation), 12);
f.read(reinterpret_cast<char*>(&kf.rotation), 16);
f.read(reinterpret_cast<char*>(&kf.scale), 12);
anim.boneKeyframes[bi].push_back(kf);
}
}
}
}
}
// WOM3: read batches (multi-material support)
if (isV3) {
uint32_t batchCount = 0;
if (f.read(reinterpret_cast<char*>(&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<char*>(&b.indexStart), 4);
f.read(reinterpret_cast<char*>(&b.indexCount), 4);
f.read(reinterpret_cast<char*>(&b.textureIndex), 4);
f.read(reinterpret_cast<char*>(&b.blendMode), 2);
f.read(reinterpret_cast<char*>(&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;
}
bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath) {
namespace fs = std::filesystem;
fs::create_directories(fs::path(basePath).parent_path());
std::string womPath = basePath + ".wom";
std::ofstream f(womPath, std::ios::binary);
if (!f) return false;
bool hasAnim = model.hasAnimation();
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<const char*>(&magic), 4);
uint32_t vertCount = static_cast<uint32_t>(model.vertices.size());
uint32_t indexCount = static_cast<uint32_t>(model.indices.size());
uint32_t texCount = static_cast<uint32_t>(model.texturePaths.size());
f.write(reinterpret_cast<const char*>(&vertCount), 4);
f.write(reinterpret_cast<const char*>(&indexCount), 4);
f.write(reinterpret_cast<const char*>(&texCount), 4);
f.write(reinterpret_cast<const char*>(&model.boundRadius), 4);
f.write(reinterpret_cast<const char*>(&model.boundMin), 12);
f.write(reinterpret_cast<const char*>(&model.boundMax), 12);
uint16_t nameLen = static_cast<uint16_t>(model.name.size());
f.write(reinterpret_cast<const char*>(&nameLen), 2);
f.write(model.name.data(), nameLen);
// WOM2/WOM3 write full vertex with bone data; WOM1 writes 32-byte vertex
if (hasAnim || hasBatches) {
f.write(reinterpret_cast<const char*>(model.vertices.data()),
vertCount * sizeof(WoweeModel::Vertex));
} else {
for (const auto& v : model.vertices) {
f.write(reinterpret_cast<const char*>(&v.position), 12);
f.write(reinterpret_cast<const char*>(&v.normal), 12);
f.write(reinterpret_cast<const char*>(&v.texCoord), 8);
}
}
f.write(reinterpret_cast<const char*>(model.indices.data()), indexCount * 4);
for (const auto& path : model.texturePaths) {
uint16_t pathLen = static_cast<uint16_t>(path.size());
f.write(reinterpret_cast<const char*>(&pathLen), 2);
f.write(path.data(), pathLen);
}
// WOM2/WOM3: write bones and animations (always, even if empty for WOM3)
if (hasAnim || hasBatches) {
uint32_t boneCount = static_cast<uint32_t>(model.bones.size());
f.write(reinterpret_cast<const char*>(&boneCount), 4);
for (const auto& bone : model.bones) {
f.write(reinterpret_cast<const char*>(&bone.keyBoneId), 4);
f.write(reinterpret_cast<const char*>(&bone.parentBone), 2);
f.write(reinterpret_cast<const char*>(&bone.pivot), 12);
f.write(reinterpret_cast<const char*>(&bone.flags), 4);
}
uint32_t animCount = static_cast<uint32_t>(model.animations.size());
f.write(reinterpret_cast<const char*>(&animCount), 4);
for (const auto& anim : model.animations) {
f.write(reinterpret_cast<const char*>(&anim.id), 4);
f.write(reinterpret_cast<const char*>(&anim.durationMs), 4);
f.write(reinterpret_cast<const char*>(&anim.movingSpeed), 4);
for (size_t bi = 0; bi < model.bones.size(); bi++) {
uint32_t kfCount = (bi < anim.boneKeyframes.size())
? static_cast<uint32_t>(anim.boneKeyframes[bi].size()) : 0;
f.write(reinterpret_cast<const char*>(&kfCount), 4);
for (uint32_t ki = 0; ki < kfCount; ki++) {
const auto& kf = anim.boneKeyframes[bi][ki];
f.write(reinterpret_cast<const char*>(&kf.timeMs), 4);
f.write(reinterpret_cast<const char*>(&kf.translation), 12);
f.write(reinterpret_cast<const char*>(&kf.rotation), 16);
f.write(reinterpret_cast<const char*>(&kf.scale), 12);
}
}
}
}
// WOM3: write batches
if (hasBatches) {
uint32_t batchCount = static_cast<uint32_t>(model.batches.size());
f.write(reinterpret_cast<const char*>(&batchCount), 4);
for (const auto& b : model.batches) {
f.write(reinterpret_cast<const char*>(&b.indexStart), 4);
f.write(reinterpret_cast<const char*>(&b.indexCount), 4);
f.write(reinterpret_cast<const char*>(&b.textureIndex), 4);
f.write(reinterpret_cast<const char*>(&b.blendMode), 2);
f.write(reinterpret_cast<const char*>(&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;
}
WoweeModel WoweeModelLoader::fromM2(const std::string& m2Path, AssetManager* am) {
WoweeModel model;
if (!am) return model;
auto data = am->readFile(m2Path);
if (data.empty()) return model;
auto m2 = M2Loader::load(data);
if (!m2.isValid()) {
std::string skinPath = m2Path;
auto dotPos = skinPath.rfind('.');
if (dotPos != std::string::npos)
skinPath = skinPath.substr(0, dotPos) + "00.skin";
auto skinData = am->readFile(skinPath);
if (!skinData.empty())
M2Loader::loadSkin(skinData, m2);
}
if (!m2.isValid()) return model;
model.name = m2.name;
model.boundRadius = m2.boundRadius;
// 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);
}
model.indices.reserve(m2.indices.size());
for (uint16_t idx : m2.indices)
model.indices.push_back(static_cast<uint32_t>(idx));
for (const auto& tex : m2.textures) {
std::string path = tex.filename;
auto dot = path.rfind('.');
if (dot != std::string::npos)
path = path.substr(0, dot) + ".png";
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 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<uint32_t>(std::max<int16_t>(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;
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<uint32_t>(&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;
}
M2Model WoweeModelLoader::toM2(const WoweeModel& wom) {
M2Model m;
if (!wom.isValid()) return m;
m.name = wom.name;
m.boundRadius = wom.boundRadius;
m.vertices.reserve(wom.vertices.size());
for (const auto& v : wom.vertices) {
M2Vertex mv;
mv.position = v.position;
mv.normal = v.normal;
mv.texCoords[0] = v.texCoord;
std::memcpy(mv.boneWeights, v.boneWeights, 4);
std::memcpy(mv.boneIndices, v.boneIndices, 4);
m.vertices.push_back(mv);
}
m.indices.reserve(wom.indices.size());
for (uint32_t idx : wom.indices)
m.indices.push_back(static_cast<uint16_t>(idx));
for (const auto& tp : wom.texturePaths) {
M2Texture tex;
tex.type = 0;
tex.flags = 0;
tex.filename = tp;
m.textures.push_back(tex);
}
m.textureLookup.clear();
for (uint32_t i = 0; i < wom.texturePaths.size(); i++)
m.textureLookup.push_back(static_cast<int16_t>(i));
if (m.textureLookup.empty()) m.textureLookup.push_back(0);
if (wom.hasBatches()) {
for (const auto& wb : wom.batches) {
M2Batch batch{};
batch.indexStart = wb.indexStart;
batch.indexCount = wb.indexCount;
batch.vertexCount = static_cast<uint32_t>(m.vertices.size());
batch.textureCount = 1;
batch.textureIndex = static_cast<uint16_t>(
std::min<uint32_t>(wb.textureIndex, m.textureLookup.size() - 1));
batch.materialIndex = static_cast<uint16_t>(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<uint32_t>(wom.texturePaths.size()));
batch.indexCount = static_cast<uint32_t>(m.indices.size());
batch.vertexCount = static_cast<uint32_t>(m.vertices.size());
m.batches.push_back(batch);
M2Material mat;
mat.flags = 0;
mat.blendMode = 0;
m.materials.push_back(mat);
}
return m;
}
WoweeModel WoweeModelLoader::tryLoadByGamePath(const std::string& gamePath) {
std::string base = gamePath;
auto dot = base.rfind('.');
if (dot != std::string::npos) base = base.substr(0, dot);
std::replace(base.begin(), base.end(), '\\', '/');
for (const char* prefix : {"custom_zones/models/", "output/models/"}) {
std::string full = std::string(prefix) + base;
if (exists(full)) {
auto wom = load(full);
if (wom.isValid()) return wom;
}
}
return {};
}
} // namespace pipeline
} // namespace wowee