Files
vr-poser/src/SkeletonLoader.cpp
2026-03-16 03:49:43 -04:00

504 lines
23 KiB
C++

#include "SkeletonLoader.h"
#include "MorphManager.h"
#include <iostream>
#include <filesystem>
#include <functional>
#include <unordered_set>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
#include <osg/Geode>
#include <osg/Geometry>
#include <osg/Material>
#include <osg/Texture2D>
#include <osg/BlendFunc>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osgAnimation/RigGeometry>
#include <osgAnimation/MorphGeometry>
#include <osgAnimation/UpdateBone>
#include <osgAnimation/StackedTransform>
#include <osgAnimation/StackedQuaternionElement>
#include <osgAnimation/StackedTranslateElement>
namespace fs = std::filesystem;
// ── Helpers ───────────────────────────────────────────────────────────────────
static osg::ref_ptr<osg::Texture2D> loadTex(const std::string& path) {
auto tryLoad = [](const std::string& p) -> osg::ref_ptr<osg::Texture2D> {
auto img = osgDB::readImageFile(p);
if (!img) return {};
auto t = new osg::Texture2D(img);
t->setWrap(osg::Texture::WRAP_S, osg::Texture::REPEAT);
t->setWrap(osg::Texture::WRAP_T, osg::Texture::REPEAT);
t->setFilter(osg::Texture::MIN_FILTER, osg::Texture::LINEAR_MIPMAP_LINEAR);
t->setFilter(osg::Texture::MAG_FILTER, osg::Texture::LINEAR);
return t;
};
if (auto t = tryLoad(path)) return t;
auto dot = path.rfind('.');
if (dot != std::string::npos)
for (auto ext : {".jpg",".jpeg",".png",".tga",".bmp"})
if (auto t = tryLoad(path.substr(0, dot) + ext)) return t;
return {};
}
static osg::Matrix aiToOsg(const aiMatrix4x4& m) {
// Assimp: row-major, row[i] = (a,b,c,d)[i], translation in col 4 (a4,b4,c4)
// OSG Matrix(a,b,c,...) fills row-by-row, translation in row 3 (indices [12],[13],[14])
// Transpose is needed to convert between the two conventions.
return osg::Matrix(
m.a1, m.b1, m.c1, m.d1,
m.a2, m.b2, m.c2, m.d2,
m.a3, m.b3, m.c3, m.d3,
m.a4, m.b4, m.c4, m.d4);
}
// Correctly transposed version for inverse bind matrices
// (offset matrix goes mesh→bone, needs proper convention)
static osg::Matrix aiToOsgTransposed(const aiMatrix4x4& m) {
return osg::Matrix(
m.a1, m.a2, m.a3, m.a4,
m.b1, m.b2, m.b3, m.b4,
m.c1, m.c2, m.c3, m.c4,
m.d1, m.d2, m.d3, m.d4);
}
// ── applyMaterial ─────────────────────────────────────────────────────────────
void SkeletonLoader::applyMaterial(osg::StateSet* ss, const aiMesh* mesh,
const aiScene* scene, const std::string& baseDir) {
if (mesh->mMaterialIndex >= scene->mNumMaterials) return;
const aiMaterial* mat = scene->mMaterials[mesh->mMaterialIndex];
auto osgMat = new osg::Material;
aiColor4D col;
if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_DIFFUSE, col))
osgMat->setDiffuse (osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a});
if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_AMBIENT, col))
osgMat->setAmbient (osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a});
if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_SPECULAR, col))
osgMat->setSpecular(osg::Material::FRONT_AND_BACK,{col.r,col.g,col.b,col.a});
float shin = 0;
if (AI_SUCCESS == mat->Get(AI_MATKEY_SHININESS, shin))
osgMat->setShininess(osg::Material::FRONT_AND_BACK, std::min(shin,128.f));
ss->setAttribute(osgMat, osg::StateAttribute::ON);
if (mat->GetTextureCount(aiTextureType_DIFFUSE) > 0) {
aiString tp; mat->GetTexture(aiTextureType_DIFFUSE, 0, &tp);
std::string full = baseDir + "/" + tp.C_Str();
for (char& c : full) if (c=='\\') c='/';
if (auto tex = loadTex(full))
ss->setTextureAttributeAndModes(0, tex, osg::StateAttribute::ON);
}
float opacity = 1.f; mat->Get(AI_MATKEY_OPACITY, opacity);
if (opacity < 1.f) {
ss->setMode(GL_BLEND, osg::StateAttribute::ON);
ss->setRenderingHint(osg::StateSet::TRANSPARENT_BIN);
ss->setAttribute(new osg::BlendFunc(osg::BlendFunc::SRC_ALPHA,
osg::BlendFunc::ONE_MINUS_SRC_ALPHA));
}
}
// ── convertStaticMesh ─────────────────────────────────────────────────────────
osg::ref_ptr<osg::Geode> SkeletonLoader::convertStaticMesh(
const aiMesh* mesh, const aiScene* scene,
const std::string& baseDir, MorphManager* morphMgr) {
auto geode = new osg::Geode;
auto geom = new osg::Geometry;
auto baseVerts = new osg::Vec3Array;
baseVerts->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
baseVerts->push_back({mesh->mVertices[i].x,mesh->mVertices[i].y,mesh->mVertices[i].z});
geom->setVertexArray(new osg::Vec3Array(*baseVerts));
osg::ref_ptr<osg::Vec3Array> baseNormals;
if (mesh->HasNormals()) {
baseNormals = new osg::Vec3Array;
baseNormals->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
baseNormals->push_back({mesh->mNormals[i].x,mesh->mNormals[i].y,mesh->mNormals[i].z});
geom->setNormalArray(new osg::Vec3Array(*baseNormals), osg::Array::BIND_PER_VERTEX);
}
if (mesh->HasTextureCoords(0)) {
auto uvs = new osg::Vec2Array; uvs->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
uvs->push_back({mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y});
geom->setTexCoordArray(0, uvs, osg::Array::BIND_PER_VERTEX);
}
auto indices = new osg::DrawElementsUInt(osg::PrimitiveSet::TRIANGLES);
for (unsigned f = 0; f < mesh->mNumFaces; ++f) {
if (mesh->mFaces[f].mNumIndices != 3) continue;
indices->push_back(mesh->mFaces[f].mIndices[0]);
indices->push_back(mesh->mFaces[f].mIndices[1]);
indices->push_back(mesh->mFaces[f].mIndices[2]);
}
if (indices->empty()) return geode;
geom->addPrimitiveSet(indices);
applyMaterial(geom->getOrCreateStateSet(), mesh, scene, baseDir);
if (morphMgr && mesh->mNumAnimMeshes > 0) {
geom->setDataVariance(osg::Object::DYNAMIC);
geom->setUseDisplayList(false);
geom->setUseVertexBufferObjects(true);
if (auto* v = dynamic_cast<osg::Vec3Array*>(geom->getVertexArray()))
v->setDataVariance(osg::Object::DYNAMIC);
if (auto* n = dynamic_cast<osg::Vec3Array*>(geom->getNormalArray()))
n->setDataVariance(osg::Object::DYNAMIC);
morphMgr->registerMesh(geom, baseVerts, baseNormals);
int reg = 0;
for (unsigned a = 0; a < mesh->mNumAnimMeshes; ++a) {
const aiAnimMesh* am = mesh->mAnimMeshes[a];
std::string name = am->mName.C_Str();
if (name.empty() || (name.size()>3&&name.front()=='-'&&name.back()=='-')) continue;
if (am->mNumVertices != mesh->mNumVertices) continue;
auto dv = new osg::Vec3Array; dv->resize(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
(*dv)[i]={am->mVertices[i].x-mesh->mVertices[i].x,
am->mVertices[i].y-mesh->mVertices[i].y,
am->mVertices[i].z-mesh->mVertices[i].z};
osg::ref_ptr<osg::Vec3Array> dn;
if (am->mNormals && mesh->HasNormals()) {
dn = new osg::Vec3Array; dn->resize(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
(*dn)[i]={am->mNormals[i].x-mesh->mNormals[i].x,
am->mNormals[i].y-mesh->mNormals[i].y,
am->mNormals[i].z-mesh->mNormals[i].z};
}
morphMgr->addTarget(geom, name, dv, dn);
++reg;
}
if (reg) std::cout << "[skel] Static \"" << mesh->mName.C_Str()
<< "\": " << reg << " morphs\n";
}
geode->addDrawable(geom);
return geode;
}
// ── convertSkinnedMesh ────────────────────────────────────────────────────────
osg::ref_ptr<osgAnimation::RigGeometry> SkeletonLoader::convertSkinnedMesh(
const aiMesh* mesh, const aiScene* scene,
const std::string& baseDir, MorphManager* morphMgr,
const std::unordered_map<std::string,
osg::ref_ptr<osgAnimation::Bone>>& boneMap) {
auto srcGeom = new osg::Geometry;
auto verts = new osg::Vec3Array; verts->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
verts->push_back({mesh->mVertices[i].x,mesh->mVertices[i].y,mesh->mVertices[i].z});
srcGeom->setVertexArray(verts);
if (mesh->HasNormals()) {
auto norms = new osg::Vec3Array; norms->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
norms->push_back({mesh->mNormals[i].x,mesh->mNormals[i].y,mesh->mNormals[i].z});
srcGeom->setNormalArray(norms, osg::Array::BIND_PER_VERTEX);
}
if (mesh->HasTextureCoords(0)) {
auto uvs = new osg::Vec2Array; uvs->reserve(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
uvs->push_back({mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y});
srcGeom->setTexCoordArray(0, uvs, osg::Array::BIND_PER_VERTEX);
}
auto indices = new osg::DrawElementsUInt(osg::PrimitiveSet::TRIANGLES);
for (unsigned f = 0; f < mesh->mNumFaces; ++f) {
if (mesh->mFaces[f].mNumIndices != 3) continue;
indices->push_back(mesh->mFaces[f].mIndices[0]);
indices->push_back(mesh->mFaces[f].mIndices[1]);
indices->push_back(mesh->mFaces[f].mIndices[2]);
}
if (!indices->empty()) srcGeom->addPrimitiveSet(indices);
applyMaterial(srcGeom->getOrCreateStateSet(), mesh, scene, baseDir);
// Build vertex influence map
auto* vim = new osgAnimation::VertexInfluenceMap;
int mappedBones = 0, skippedBones = 0;
for (unsigned b = 0; b < mesh->mNumBones; ++b) {
const aiBone* bone = mesh->mBones[b];
std::string bname = bone->mName.C_Str();
if (!boneMap.count(bname)) { ++skippedBones; continue; }
++mappedBones;
auto& vi = (*vim)[bname];
vi.setName(bname);
for (unsigned w = 0; w < bone->mNumWeights; ++w)
vi.push_back(osgAnimation::VertexIndexWeight(
bone->mWeights[w].mVertexId, bone->mWeights[w].mWeight));
}
if (skippedBones > 0)
std::cout << "[skel] Influence map \"" << mesh->mName.C_Str()
<< "\": " << mappedBones << " mapped, "
<< skippedBones << " skipped (not in boneMap)\n";
auto rig = new osgAnimation::RigGeometry;
rig->setName(mesh->mName.C_Str());
rig->setSourceGeometry(srcGeom);
rig->setInfluenceMap(vim);
rig->setDataVariance(osg::Object::DYNAMIC);
rig->setUseDisplayList(false);
rig->setUseVertexBufferObjects(true);
// Register morphs on the source geometry if present
// RigGeometry deforms the source geometry, so morphs must target it
if (morphMgr && mesh->mNumAnimMeshes > 0) {
auto baseVerts = dynamic_cast<osg::Vec3Array*>(srcGeom->getVertexArray());
auto baseNorms = dynamic_cast<osg::Vec3Array*>(srcGeom->getNormalArray());
if (baseVerts) {
srcGeom->setDataVariance(osg::Object::DYNAMIC);
morphMgr->registerMesh(srcGeom, baseVerts,
osg::ref_ptr<osg::Vec3Array>(baseNorms));
int reg = 0;
for (unsigned a = 0; a < mesh->mNumAnimMeshes; ++a) {
const aiAnimMesh* am = mesh->mAnimMeshes[a];
std::string mname = am->mName.C_Str();
if (mname.empty() || (mname.size()>3 &&
mname.front()=='-' && mname.back()=='-')) continue;
if (am->mNumVertices != mesh->mNumVertices) continue;
auto dv = new osg::Vec3Array; dv->resize(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
(*dv)[i] = {am->mVertices[i].x - mesh->mVertices[i].x,
am->mVertices[i].y - mesh->mVertices[i].y,
am->mVertices[i].z - mesh->mVertices[i].z};
osg::ref_ptr<osg::Vec3Array> dn;
if (am->mNormals && mesh->HasNormals()) {
dn = new osg::Vec3Array; dn->resize(mesh->mNumVertices);
for (unsigned i = 0; i < mesh->mNumVertices; ++i)
(*dn)[i] = {am->mNormals[i].x - mesh->mNormals[i].x,
am->mNormals[i].y - mesh->mNormals[i].y,
am->mNormals[i].z - mesh->mNormals[i].z};
}
morphMgr->addTarget(srcGeom, mname, dv, dn);
++reg;
}
if (reg) std::cout << "[skel] Rig \"" << mesh->mName.C_Str()
<< "\" morphs=" << reg << "\n";
}
}
std::cout << "[skel] Rig \"" << mesh->mName.C_Str()
<< "\" bones=" << mesh->mNumBones
<< " verts=" << mesh->mNumVertices << "\n";
return rig;
}
// ── buildBoneTree ─────────────────────────────────────────────────────────────
osg::ref_ptr<osgAnimation::Bone> SkeletonLoader::buildBoneTree(
const aiNode* node,
const std::unordered_map<std::string, bool>& boneNames,
std::unordered_map<std::string,
osg::ref_ptr<osgAnimation::Bone>>& boneMap,
const osg::Matrix& parentAccum) {
std::string name = node->mName.C_Str();
// Assimp injects "$AssimpFbx$_Translation" / "$AssimpFbx$_PreRotation"
// helper nodes between real bone nodes when reading FBX files.
// We must pass through them transparently to reach the real bone children.
if (!boneNames.count(name)) {
// "$AssimpFbx$_Translation", "$AssimpFbx$_PreRotation" etc.
// These helpers carry transform data that belongs to the next real bone.
// Accumulate them and pass the combined matrix down to the real bone.
// Chain this helper's transform onto the incoming accumulation
osg::Matrix accumulated = parentAccum * aiToOsg(node->mTransformation);
for (unsigned i = 0; i < node->mNumChildren; ++i) {
auto child = buildBoneTree(node->mChildren[i], boneNames, boneMap,
accumulated);
if (child) return child;
}
return {};
}
// Bail if already built (prevents double-add via multiple helper paths)
if (boneMap.count(name)) return boneMap[name];
// Combine accumulated parent helper transforms with this bone's own transform
osg::Matrix localMat = parentAccum * aiToOsg(node->mTransformation);
auto bone = new osgAnimation::Bone(name);
bone->setMatrix(localMat);
auto updateCB = new osgAnimation::UpdateBone(name);
osg::Vec3 t = localMat.getTrans();
osg::Quat q; osg::Vec3 sc; osg::Quat so;
localMat.decompose(t, q, sc, so);
auto& stack = updateCB->getStackedTransforms();
stack.push_back(new osgAnimation::StackedTranslateElement("translate", t));
stack.push_back(new osgAnimation::StackedQuaternionElement("quaternion", q));
bone->setUpdateCallback(updateCB);
boneMap[name] = bone; // register BEFORE recursing to break cycles
for (unsigned i = 0; i < node->mNumChildren; ++i) {
auto child = buildBoneTree(node->mChildren[i], boneNames, boneMap,
osg::Matrix::identity());
if (child && child.get() != bone && child->getNumParents() == 0)
bone->addChild(child);
}
return bone;
}
// ── findArmatureRoot ──────────────────────────────────────────────────────────
const aiNode* SkeletonLoader::findArmatureRoot(
const aiNode* node,
const std::unordered_map<std::string, bool>& boneNames) {
if (boneNames.count(node->mName.C_Str())) return node;
for (unsigned i = 0; i < node->mNumChildren; ++i)
if (auto f = findArmatureRoot(node->mChildren[i], boneNames)) return f;
return nullptr;
}
// ── load ──────────────────────────────────────────────────────────────────────
SkeletonLoader::Result SkeletonLoader::load(
const aiScene* scene, const std::string& baseDir, MorphManager* morphMgr) {
Result result;
// Collect bone names + inverse bind matrices
std::unordered_map<std::string, bool> boneNames;
std::unordered_map<std::string, osg::Matrix> invBindMatrices;
for (unsigned m = 0; m < scene->mNumMeshes; ++m) {
const aiMesh* mesh = scene->mMeshes[m];
for (unsigned b = 0; b < mesh->mNumBones; ++b) {
const aiBone* bone = mesh->mBones[b];
boneNames[bone->mName.C_Str()] = true;
if (!invBindMatrices.count(bone->mName.C_Str()))
invBindMatrices[bone->mName.C_Str()] = aiToOsgTransposed(bone->mOffsetMatrix);
}
}
if (boneNames.empty()) { std::cerr << "[skel] No bones.\n"; return result; }
std::cout << "[skel] Found " << boneNames.size() << " bones.\n";
// Invert offset matrices in Assimp space to get bind-pose world positions.
// This must be done BEFORE any OSG conversion to avoid matrix convention issues.
for (unsigned m = 0; m < scene->mNumMeshes; ++m) {
const aiMesh* mesh = scene->mMeshes[m];
for (unsigned b = 0; b < mesh->mNumBones; ++b) {
const aiBone* bone = mesh->mBones[b];
std::string name = bone->mName.C_Str();
if (result.bindPositions.count(name)) continue;
aiMatrix4x4 inv = bone->mOffsetMatrix;
inv.Inverse();
// Translation is in (a4, b4, c4) in Assimp row-major convention
result.bindPositions[name] = osg::Vec3(inv.a4, inv.b4, inv.c4);
}
}
// Build skeleton
auto skeleton = new osgAnimation::Skeleton;
skeleton->setName("Skeleton");
skeleton->setDefaultUpdateCallback();
result.skeleton = skeleton;
const aiNode* armRoot = findArmatureRoot(scene->mRootNode, boneNames);
if (!armRoot) { std::cerr << "[skel] No armature root.\n"; return result; }
auto rootBone = buildBoneTree(armRoot, boneNames, result.boneMap,
osg::Matrix::identity());
if (!rootBone) { std::cerr << "[skel] Bone tree failed.\n"; return result; }
skeleton->addChild(rootBone);
std::cout << "[skel] Bone tree: " << result.boneMap.size() << " bones.\n";
// Recompute inverse bind matrices from our actual bone world positions.
// We can't use Assimp's mOffsetMatrix directly because we accumulated
// helper node transforms differently, so the bone world matrices we built
// don't match what Assimp computed. Instead, walk the bone tree to get
// each bone's world matrix and invert it.
{
// Build world matrices by walking the bone hierarchy
std::unordered_map<std::string, osg::Matrix> worldMatrices;
std::function<void(osgAnimation::Bone*, const osg::Matrix&)> computeWorld =
[&](osgAnimation::Bone* bone, const osg::Matrix& parentWorld) {
osg::Matrix world = bone->getMatrix() * parentWorld;
worldMatrices[bone->getName()] = world;
for (unsigned i = 0; i < bone->getNumChildren(); ++i) {
auto* child = dynamic_cast<osgAnimation::Bone*>(bone->getChild(i));
if (child) computeWorld(child, world);
}
};
computeWorld(rootBone.get(), osg::Matrix::identity());
for (auto& [name, bone] : result.boneMap) {
auto it = worldMatrices.find(name);
if (it != worldMatrices.end()) {
osg::Matrix invBind = osg::Matrix::inverse(it->second);
bone->setInvBindMatrixInSkeletonSpace(invBind);
}
}
}
// ── Scene graph ───────────────────────────────────────────────────────────
// RigGeometry finds its Skeleton by walking UP the parent path.
// So all mesh nodes must be DESCENDANTS of the Skeleton node.
//
// result.root
// skeleton <- ancestor of all RigGeometry
// meshGroup <- mesh nodes go here
// Body xform -> Geode -> RigGeometry
// ...
// rootBone (already added above)
result.root = new osg::Group;
result.root->setName("SkinnedModel");
result.root->addChild(skeleton);
// The skeleton populates its internal bone map automatically during
// the first update traversal via its default update callback.
auto meshGroup = new osg::Group;
meshGroup->setName("Meshes");
skeleton->addChild(meshGroup); // meshes are children of skeleton
std::function<void(const aiNode*, osg::Group*)> walkNodes =
[&](const aiNode* node, osg::Group* parent) {
if (boneNames.count(node->mName.C_Str())) return;
osg::Matrix nodeMat = aiToOsg(node->mTransformation);
auto xform = new osg::MatrixTransform(nodeMat);
xform->setName(node->mName.C_Str());
for (unsigned mi = 0; mi < node->mNumMeshes; ++mi) {
const aiMesh* mesh = scene->mMeshes[node->mMeshes[mi]];
auto geode = new osg::Geode;
if (mesh->mNumBones > 0) {
auto rig = convertSkinnedMesh(mesh, scene, baseDir,
morphMgr, result.boneMap);
if (rig) geode->addDrawable(rig);
} else {
auto sg = convertStaticMesh(mesh, scene, baseDir, morphMgr);
for (unsigned d = 0; d < sg->getNumDrawables(); ++d)
geode->addDrawable(sg->getDrawable(d));
}
if (geode->getNumDrawables() > 0) xform->addChild(geode);
}
parent->addChild(xform);
for (unsigned ci = 0; ci < node->mNumChildren; ++ci)
walkNodes(node->mChildren[ci], xform);
};
for (unsigned n = 0; n < scene->mRootNode->mNumChildren; ++n)
walkNodes(scene->mRootNode->mChildren[n], meshGroup);
result.valid = true;
std::cout << "[skel] Done. " << result.boneMap.size() << " bones.\n";
return result;
}