mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-06 00:53:52 +00:00
feat: WOM2 animated model format with bones and keyframe animation
Upgrades WOM from geometry-only (WOM1) to fully animated (WOM2): - WOM2 magic (0x324D4F57) for animated models, WOM1 for static - Vertex extended: +boneWeights[4] +boneIndices[4] (40 bytes vs 32) - Bone data: keyBoneId, parentBone, pivot, flags per bone - Animation data: per-sequence per-bone keyframes with translation, rotation (quaternion), scale at millisecond timestamps - fromM2() now preserves all skeletal data: bone hierarchy, weights, and per-sequence keyframes from M2 animation tracks - Backward compatible: WOM1 files load without bone data (32-byte vertices read and padded with default bone weights) - FORMAT_SPEC.md updated with WOM2 binary layout
This commit is contained in:
parent
109b288573
commit
f6dfc295ab
3 changed files with 225 additions and 27 deletions
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtc/quaternion.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
|
@ -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<std::vector<AnimKeyframe>> boneKeyframes; // [boneIdx][keyframe]
|
||||
};
|
||||
|
||||
std::string name;
|
||||
std::vector<Vertex> vertices;
|
||||
std::vector<uint32_t> indices;
|
||||
std::vector<std::string> texturePaths; // PNG paths
|
||||
std::vector<std::string> texturePaths;
|
||||
std::vector<Bone> bones;
|
||||
std::vector<Animation> 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 {
|
||||
|
|
|
|||
|
|
@ -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<char*>(&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<char*>(&vertCount), 4);
|
||||
|
|
@ -34,23 +37,31 @@ WoweeModel WoweeModelLoader::load(const std::string& basePath) {
|
|||
f.read(reinterpret_cast<char*>(&model.boundMin), 12);
|
||||
f.read(reinterpret_cast<char*>(&model.boundMax), 12);
|
||||
|
||||
// Read name
|
||||
uint16_t nameLen;
|
||||
f.read(reinterpret_cast<char*>(&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<char*>(model.vertices.data()),
|
||||
vertCount * sizeof(WoweeModel::Vertex));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Read indices
|
||||
model.indices.resize(indexCount);
|
||||
f.read(reinterpret_cast<char*>(model.indices.data()),
|
||||
indexCount * sizeof(uint32_t));
|
||||
f.read(reinterpret_cast<char*>(model.indices.data()), indexCount * 4);
|
||||
|
||||
// Read texture paths
|
||||
for (uint32_t i = 0; i < texCount; i++) {
|
||||
uint16_t pathLen;
|
||||
f.read(reinterpret_cast<char*>(&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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<const char*>(&WOM_MAGIC), 4);
|
||||
bool hasAnim = model.hasAnimation();
|
||||
uint32_t 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());
|
||||
|
|
@ -87,10 +141,19 @@ bool WoweeModelLoader::save(const WoweeModel& model, const std::string& basePath
|
|||
f.write(reinterpret_cast<const char*>(&nameLen), 2);
|
||||
f.write(model.name.data(), nameLen);
|
||||
|
||||
f.write(reinterpret_cast<const char*>(model.vertices.data()),
|
||||
vertCount * sizeof(WoweeModel::Vertex));
|
||||
f.write(reinterpret_cast<const char*>(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<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());
|
||||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<uint32_t>(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<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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue