mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-06 17:13:51 +00:00
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.
466 lines
19 KiB
C++
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
|