This commit is contained in:
2026-03-16 00:22:49 -04:00
parent 6eca497b1f
commit 60873c50b2
14 changed files with 1486 additions and 24 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
/build
/build
imgui.ini

View File

@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
endif()
find_package(OpenSceneGraph REQUIRED COMPONENTS
osgDB osgGA osgUtil osgViewer osgText osgSim)
osgDB osgGA osgUtil osgViewer osgText osgSim osgAnimation)
find_package(assimp REQUIRED)
find_package(OpenGL REQUIRED)
find_package(PkgConfig REQUIRED)
@@ -26,6 +26,9 @@ set(SOURCES
src/OrbitManipulator.cpp
src/ShaderManager.cpp
src/AppConfig.cpp
src/SkeletonLoader.cpp
src/PoseManager.cpp
src/BoneSelector.cpp
)
add_executable(${PROJECT_NAME} ${SOURCES})

View File

@@ -14,9 +14,12 @@
#include "ShaderManager.h"
#include "AppConfig.h"
#include "PoseManager.h"
class MorphManager;
class ImGuiLayer;
class SkeletonLoader;
class BoneSelector;
class Application : public osgGA::GUIEventHandler {
public:
@@ -33,27 +36,35 @@ public:
void applyPendingShader(); // called by update callback
void applyMorphWeights(); // called by update callback
void applyPoseUpdate(); // called by update callback
private:
void setupLighting();
void setupGrid();
void requestShader(const std::string& mode);
void setModelScale(float scale);
void setPoseMode(bool enabled);
void handleViewportClick(float x, float y);
osg::ref_ptr<osgViewer::Viewer> m_viewer;
osg::ref_ptr<osg::Group> m_sceneRoot;
osg::ref_ptr<osg::Group> m_shaderGroup;
osg::ref_ptr<osg::Node> m_modelNode;
osg::ref_ptr<osg::MatrixTransform> m_modelXform; // scale/transform wrapper
osg::ref_ptr<osg::MatrixTransform> m_modelXform;
osg::ref_ptr<osg::Group> m_skeletonRoot;
AppConfig m_config;
std::unique_ptr<ShaderManager> m_shaderMgr;
std::unique_ptr<MorphManager> m_morphMgr;
std::unique_ptr<PoseManager> m_poseMgr;
std::unique_ptr<BoneSelector> m_boneSel;
std::unique_ptr<ImGuiLayer> m_imguiLayer;
bool m_poseMode = false;
std::mutex m_shaderMutex;
std::string m_pendingShader;
std::string m_currentShader = "toon";
bool m_shaderDirty = false;
bool m_reloadShaders = false;
};
};

55
include/BoneSelector.h Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#include <string>
#include <vector>
#include <osg/ref_ptr>
#include <osg/Vec3>
#include <osg/Geode>
#include <osg/Group>
#include <osg/Geometry>
#include <osgViewer/Viewer>
#include <osgAnimation/Bone>
class PoseManager;
class BoneSelector {
public:
explicit BoneSelector(PoseManager* poseMgr);
void init(osgViewer::Viewer* viewer, osg::Group* sceneRoot);
/// Update bone positions each frame. Only rebuilds geometry when
/// selection changes — sphere positions update via vertex array dirty().
void updateVisualization();
std::string pick(float mouseX, float mouseY, osgViewer::Viewer* viewer);
void setSelected(const std::string& boneName);
const std::string& selected() const { return m_selected; }
void setVisible(bool v);
bool isVisible() const { return m_visible; }
private:
void buildInitialGeometry(); // called once on init
void updatePositions(); // cheap per-frame position update
PoseManager* m_poseMgr = nullptr;
osg::ref_ptr<osg::Group> m_root;
osg::ref_ptr<osg::Geode> m_lines;
osg::ref_ptr<osg::Geode> m_spheres;
// Line geometry arrays — updated in place each frame
osg::ref_ptr<osg::Vec3Array> m_lineVerts;
osg::ref_ptr<osg::Vec4Array> m_lineColors;
// Sphere positions — one MatrixTransform per bone
std::vector<osg::ref_ptr<osg::MatrixTransform>> m_sphereXforms;
std::vector<std::string> m_sphereBoneNames;
std::string m_selected;
std::string m_prevSelected; // detect selection changes
bool m_visible = true;
bool m_geometryBuilt = false;
};

View File

@@ -7,45 +7,70 @@
#include <osgGA/GUIEventAdapter>
class MorphManager;
class PoseManager;
class BoneSelector;
class AppConfig;
class ImGuiLayer {
public:
explicit ImGuiLayer(MorphManager* morphMgr, const AppConfig* cfg);
explicit ImGuiLayer(MorphManager* morphMgr,
const AppConfig* cfg);
~ImGuiLayer();
void init(osgViewer::Viewer* viewer);
bool handleEvent(const osgGA::GUIEventAdapter& ea);
void renderPanel(); // called by ImGuiDrawCallback each frame
void renderPanel();
void markGLInitialized();
// Callbacks set by Application so ImGui can drive app state
// Callbacks
std::function<void(const std::string&)> onShaderChange;
std::function<void()> onShaderReload;
std::function<void(float)> onScaleChange;
// Pose callbacks
std::function<void(bool)> onPoseModeToggle;
std::function<void(bool)> onBoneVisToggle;
// Called by Application each frame so the shader tab shows current state
void setCurrentShader(const std::string& s) { m_currentShader = s; }
void setInitialScale(float s) { m_scale = s; m_scaleBuf[0] = 0; }
// Set pose subsystem references so the Pose tab can drive them
void setPoseManager(PoseManager* pm) { m_poseMgr = pm; }
void setBoneSelector(BoneSelector* bs) { m_boneSel = bs; }
// Called by Application when the user clicks a bone in the viewport
void selectBone(const std::string& name) { m_selectedBone = name; }
private:
void renderMorphTab();
void renderShaderTab();
void renderTransformTab();
void renderPoseTab();
void renderBoneTree(const std::string& boneName, int depth);
MorphManager* m_morphMgr = nullptr;
PoseManager* m_poseMgr = nullptr;
BoneSelector* m_boneSel = nullptr;
const AppConfig* m_cfg = nullptr;
osgViewer::Viewer* m_viewer = nullptr;
osg::ref_ptr<osg::Camera> m_camera;
bool m_contextCreated = false;
bool m_glInitialized = false;
float m_panelWidth = 380.f; // wider default so names are visible
float m_panelWidth = 380.f;
char m_searchBuf[256] = {};
bool m_showOnlyActive = false;
char m_searchBuf[256] = {};
char m_boneSearch[256] = {};
bool m_showOnlyActive = false;
std::string m_currentShader = "toon";
float m_scale = 1.0f;
char m_scaleBuf[32] = {};
};
// Pose tab state
bool m_poseEnabled = false;
bool m_bonesVisible = true;
std::string m_selectedBone;
// Euler angles for selected bone (degrees, for display)
float m_boneEuler[3] = {};
float m_boneTrans[3] = {};
};

94
include/PoseManager.h Normal file
View File

@@ -0,0 +1,94 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include <osg/ref_ptr>
#include <osg/Quat>
#include <osg/Vec3>
#include <osg/Matrix>
#include <osg/NodeVisitor>
#include <osgAnimation/Bone>
#include <osgAnimation/Skeleton>
/**
* PoseManager
* -----------
* Owns the current pose as a map of bone name → local rotation + translation
* offsets from the bind pose. Applies them to the osgAnimation bone tree
* each frame during the update traversal.
*
* Usage:
* // At load time:
* mgr.init(skeleton, boneMap);
*
* // From ImGui / bone gizmo:
* mgr.setBoneRotation("Head", osg::Quat(...));
*
* // In update callback:
* mgr.applyPose();
*
* // Reset:
* mgr.resetBone("Head");
* mgr.resetAll();
*/
class PoseManager {
public:
struct BonePose {
osg::Quat rotation = { 0, 0, 0, 1 }; // local rotation delta
osg::Vec3 translation = { 0, 0, 0 }; // local translation delta
bool modified = false;
};
PoseManager() = default;
/// Called once after skeleton is loaded.
void init(osgAnimation::Skeleton* skeleton,
const std::unordered_map<std::string,
osg::ref_ptr<osgAnimation::Bone>>& boneMap);
/// Sorted list of all bone names (for UI tree).
const std::vector<std::string>& boneNames() const { return m_boneNames; }
/// Bone hierarchy as parent name → child names (for tree display).
const std::unordered_map<std::string, std::vector<std::string>>&
boneChildren() const { return m_children; }
std::string boneParent(const std::string& name) const;
// ── Pose setters ──────────────────────────────────────────────────────────
void setBoneRotation(const std::string& name, const osg::Quat& q);
void setBoneTranslation(const std::string& name, const osg::Vec3& t);
void resetBone(const std::string& name);
void resetAll();
// ── Pose getters ──────────────────────────────────────────────────────────
const BonePose& getBonePose(const std::string& name) const;
osg::Vec3 getBoneWorldPos(const std::string& name) const;
// ── Per-frame update ──────────────────────────────────────────────────────
/// Apply current pose to the osgAnimation bone tree.
void applyPose();
/// Run a skeleton update traversal to refresh world-space bone matrices.
/// Call every frame so getBoneWorldPos() returns current positions.
void updateSkeletonMatrices();
bool isInitialized() const { return m_initialized; }
private:
bool m_initialized = false;
unsigned int m_traversalCount = 1000;
osg::ref_ptr<osgAnimation::Skeleton> m_skeleton;
std::unordered_map<std::string, osg::ref_ptr<osgAnimation::Bone>> m_boneMap;
std::unordered_map<std::string, BonePose> m_poses;
std::unordered_map<std::string, osg::Matrix> m_bindMatrices; // local bind pose
std::unordered_map<std::string, std::string> m_parents; // child → parent
std::unordered_map<std::string, std::vector<std::string>> m_children;
std::vector<std::string> m_boneNames; // sorted
BonePose m_defaultPose; // returned for unknowns
void buildHierarchy(osgAnimation::Bone* bone, const std::string& parentName);
};

93
include/SkeletonLoader.h Normal file
View File

@@ -0,0 +1,93 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include <osg/ref_ptr>
#include <osg/Group>
#include <osgAnimation/Skeleton>
#include <osgAnimation/Bone>
#include <osgAnimation/RigGeometry>
#include <osgAnimation/MorphGeometry>
// Forward declarations
struct aiScene;
struct aiNode;
struct aiMesh;
/**
* SkeletonLoader
* --------------
* Reads the Assimp scene's bone hierarchy and mesh skin weights,
* and builds an osgAnimation scene graph:
*
* osgAnimation::Skeleton
* └─ osgAnimation::Bone (Hips)
* └─ osgAnimation::Bone (Spine)
* └─ ...
*
* Each skinned aiMesh becomes an osgAnimation::RigGeometry instead
* of a plain osg::Geometry, so the GPU handles skinning automatically.
*
* Usage:
* SkeletonLoader loader;
* auto result = loader.load(assimpScene, baseDir, morphMgr);
* // result.root — attach to scene graph
* // result.boneMap — name → Bone* for posing
*/
class MorphManager;
class SkeletonLoader {
public:
struct Result {
osg::ref_ptr<osg::Group> root;
osg::ref_ptr<osgAnimation::Skeleton> skeleton;
std::unordered_map<std::string,
osg::ref_ptr<osgAnimation::Bone>> boneMap;
bool valid = false;
};
Result load(const struct aiScene* scene,
const std::string& baseDir,
MorphManager* morphMgr = nullptr);
private:
// ── Bone hierarchy ────────────────────────────────────────────────────────
/// Recursively build Bone nodes from the aiNode tree.
osg::ref_ptr<osgAnimation::Bone> 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 = osg::Matrix::identity());
/// Find the armature root node (first ancestor whose children are all bones).
const aiNode* findArmatureRoot(const aiNode* root,
const std::unordered_map<std::string, bool>& boneNames);
// ── Mesh conversion ───────────────────────────────────────────────────────
/// Convert a skinned aiMesh to RigGeometry.
osg::ref_ptr<osgAnimation::RigGeometry> 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);
/// Convert an unskinned aiMesh to plain osg::Geometry (same as before).
osg::ref_ptr<osg::Geode> convertStaticMesh(
const aiMesh* mesh,
const aiScene* scene,
const std::string& baseDir,
MorphManager* morphMgr);
/// Shared material/texture setup used by both converters.
void applyMaterial(osg::StateSet* ss,
const aiMesh* mesh,
const aiScene* scene,
const std::string& baseDir);
};

65
logs.txt Normal file
View File

@@ -0,0 +1,65 @@
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found
0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f372abe300 RigTransformSoftware::VertexGroup no bones found
0x55f372abe300 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f372986100 RigTransformSoftware::VertexGroup no bones found
0x55f372986100 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found
0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found
0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found
0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f37527b890 RigTransformSoftware::VertexGroup no bones found
0x55f37527b890 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found
0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found
0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found
0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found
RigTransformSoftware::VertexGroup: warning try to normalize a zero sum vertexgroup
0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found
0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae050 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found
0x55f36c1ae150 RigTransformSoftware::VertexGroup no bones found
0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found
0x55f374ff0910 RigTransformSoftware::VertexGroup no bones found
0x55f372abe300 RigTransformSoftware::VertexGroup no bones found
0x55f372abe300 RigTransformSoftware::VertexGroup no bones found
0x55f372986100 RigTransformSoftware::VertexGroup no bones found
0x55f372986100 RigTransformSoftware::VertexGroup no bones found
0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found
0x55f36bd40a10 RigTransformSoftware::VertexGroup no bones found
0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found
0x55f36bf07ac0 RigTransformSoftware::VertexGroup no bones found
0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found
0x55f3755f8410 RigTransformSoftware::VertexGroup no bones found
0x55f37527b890 RigTransformSoftware::VertexGroup no bones found
0x55f37527b890 RigTransformSoftware::VertexGroup no bones found
0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found
0x55f36c180aa0 RigTransformSoftware::VertexGroup no bones found
0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found
0x55f36c1807d0 RigTransformSoftware::VertexGroup no bones found
0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found
0x55f37526bc40 RigTransformSoftware::VertexGroup no bones found
0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found
0x55f37526cb80 RigTransformSoftware::VertexGroup no bones found

View File

@@ -5,6 +5,13 @@
#include "OrbitManipulator.h"
#include "MorphManager.h"
#include "AppConfig.h"
#include "SkeletonLoader.h"
#include "PoseManager.h"
#include "BoneSelector.h"
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
#include <filesystem>
#include "ImGuiLayer.h"
#include <iostream>
@@ -15,6 +22,7 @@
#include <osg/CullFace>
#include <osg/Program>
#include <osg/NodeCallback>
#include <osg/NodeVisitor>
#include <osg/MatrixTransform>
#include <osgViewer/ViewerEventHandlers>
@@ -24,8 +32,9 @@ class AppUpdateCallback : public osg::NodeCallback {
public:
explicit AppUpdateCallback(Application* app) : m_app(app) {}
void operator()(osg::Node* node, osg::NodeVisitor* nv) override {
m_app->applyPendingShader(); // shader switches
m_app->applyMorphWeights(); // morph deformation — must be in update traversal
m_app->applyPendingShader();
m_app->applyMorphWeights();
m_app->applyPoseUpdate(); // bone posing
traverse(node, nv);
}
private:
@@ -37,6 +46,7 @@ private:
Application::Application()
: m_shaderMgr(std::make_unique<ShaderManager>("assets/shaders"))
, m_morphMgr (std::make_unique<MorphManager>())
, m_poseMgr (std::make_unique<PoseManager>())
{
m_config.load();
m_imguiLayer = std::make_unique<ImGuiLayer>(m_morphMgr.get(), &m_config);
@@ -109,18 +119,61 @@ bool Application::init(int width, int height, const std::string& title) {
bool Application::loadModel(const std::string& filepath) {
std::cout << "[app] Loading model: " << filepath << "\n";
ModelLoader loader;
m_modelNode = loader.load(filepath, m_morphMgr.get());
if (!m_modelNode) return false;
// Try skeleton loader (handles FBX with bones)
Assimp::Importer importer;
constexpr unsigned flags =
aiProcess_Triangulate | aiProcess_GenSmoothNormals |
aiProcess_SortByPType | aiProcess_ImproveCacheLocality;
const aiScene* scene = importer.ReadFile(filepath, flags);
// Wrap model in a transform so scale/position can be adjusted at runtime
if (scene && scene->mRootNode) {
const std::string baseDir =
std::filesystem::path(filepath).parent_path().string();
SkeletonLoader skelLoader;
auto skelResult = skelLoader.load(scene, baseDir, m_morphMgr.get());
if (skelResult.valid) {
m_modelNode = skelResult.root;
m_poseMgr->init(skelResult.skeleton.get(), skelResult.boneMap);
m_boneSel = std::make_unique<BoneSelector>(m_poseMgr.get());
m_boneSel->init(m_viewer.get(), m_sceneRoot.get());
m_imguiLayer->setPoseManager(m_poseMgr.get());
m_imguiLayer->setBoneSelector(m_boneSel.get());
m_imguiLayer->onPoseModeToggle = [this](bool e) { setPoseMode(e); };
m_imguiLayer->onBoneVisToggle = [this](bool v) {
if (m_boneSel) m_boneSel->setVisible(v);
};
// Force a skeleton update traversal immediately so the bone map
// is populated before the first draw call. Without this, RigGeometry
// tries to skin on frame 0 before bones are linked, collapsing verts.
// We run it twice — once to build the bone map, once to propagate.
for (int warmup = 0; warmup < 3; ++warmup) {
osg::NodeVisitor uv(osg::NodeVisitor::UPDATE_VISITOR,
osg::NodeVisitor::TRAVERSE_ALL_CHILDREN);
uv.setTraversalNumber(warmup);
skelResult.root->accept(uv);
}
std::cout << "[app] Skeleton warmup done.\n";
std::cout << "[app] Skeleton loaded: "
<< m_poseMgr->boneNames().size() << " bones.\n";
}
}
// Fallback to plain loader if skeleton load failed
if (!m_modelNode) {
ModelLoader loader;
m_modelNode = loader.load(filepath, m_morphMgr.get());
if (!m_modelNode) return false;
std::cout << "[app] Loaded as static mesh.\n";
}
// Wrap in scale transform
m_modelXform = new osg::MatrixTransform;
m_modelXform->setName("ModelTransform");
float initScale = m_config.getFloat("model.scale", 1.0f);
m_modelXform->setMatrix(osg::Matrix::scale(initScale, initScale, initScale));
m_modelXform->addChild(m_modelNode);
m_shaderGroup->addChild(m_modelXform);
requestShader(m_currentShader);
m_viewer->home();
@@ -149,6 +202,14 @@ bool Application::handle(const osgGA::GUIEventAdapter& ea,
// Forward to ImGui first — if it wants the event, don't pass to camera
if (m_imguiLayer->handleEvent(ea)) return true;
// Bone picking on left click in pose mode
if (m_poseMode &&
ea.getEventType() == osgGA::GUIEventAdapter::PUSH &&
ea.getButton() == osgGA::GUIEventAdapter::LEFT_MOUSE_BUTTON) {
handleViewportClick(ea.getX(), ea.getY());
// Don't consume — let camera manipulator also handle it
}
if (ea.getEventType() != osgGA::GUIEventAdapter::KEYDOWN) return false;
if (!m_modelNode) return false;
@@ -191,6 +252,30 @@ void Application::applyMorphWeights() {
if (m_morphMgr) m_morphMgr->applyWeights();
}
void Application::setPoseMode(bool enabled) {
m_poseMode = enabled;
if (m_boneSel) m_boneSel->setVisible(enabled);
std::cout << "[app] Pose mode: " << (enabled ? "ON" : "OFF") << "\n";
}
void Application::applyPoseUpdate() {
if (!m_poseMgr || !m_poseMgr->isInitialized()) return;
// applyPose sets local matrices, then updateSkeletonMatrices propagates FK
if (m_poseMode) m_poseMgr->applyPose();
m_poseMgr->updateSkeletonMatrices(); // always keep world positions fresh
if (m_poseMode && m_boneSel) m_boneSel->updateVisualization();
}
void Application::handleViewportClick(float x, float y) {
if (!m_poseMode || !m_boneSel) return;
std::string hit = m_boneSel->pick(x, y, m_viewer.get());
if (!hit.empty()) {
m_boneSel->setSelected(hit);
m_imguiLayer->selectBone(hit);
std::cout << "[app] Selected bone: " << hit << "\n";
}
}
void Application::setModelScale(float scale) {
if (m_modelXform) {
scale = std::max(0.001f, scale);

203
src/BoneSelector.cpp Normal file
View File

@@ -0,0 +1,203 @@
#include "BoneSelector.h"
#include "PoseManager.h"
#include <iostream>
#include <algorithm>
#include <osg/Geometry>
#include <osg/ShapeDrawable>
#include <osg/Shape>
#include <osg/LineWidth>
#include <osg/StateSet>
#include <osg/MatrixTransform>
#include <osgUtil/LineSegmentIntersector>
#include <osgUtil/IntersectionVisitor>
// ─────────────────────────────────────────────────────────────────────────────
BoneSelector::BoneSelector(PoseManager* poseMgr)
: m_poseMgr(poseMgr)
{}
// ─────────────────────────────────────────────────────────────────────────────
void BoneSelector::init(osgViewer::Viewer* viewer, osg::Group* sceneRoot) {
(void)viewer;
m_root = new osg::Group;
m_root->setName("BoneOverlay");
osg::StateSet* ss = m_root->getOrCreateStateSet();
ss->setMode(GL_LIGHTING, osg::StateAttribute::OFF);
ss->setMode(GL_DEPTH_TEST,osg::StateAttribute::OFF);
ss->setRenderBinDetails(100, "RenderBin");
sceneRoot->addChild(m_root);
// Geometry built lazily on first updateVisualization() call
}
// ─────────────────────────────────────────────────────────────────────────────
void BoneSelector::buildInitialGeometry() {
if (!m_poseMgr || !m_poseMgr->isInitialized()) return;
const auto& names = m_poseMgr->boneNames();
// ── Lines ─────────────────────────────────────────────────────────────────
m_lineVerts = new osg::Vec3Array;
m_lineColors = new osg::Vec4Array;
m_lineVerts->resize(names.size() * 2, osg::Vec3());
m_lineColors->resize(names.size() * 2, osg::Vec4(0.4f,0.8f,1.f,0.7f));
auto lineGeom = new osg::Geometry;
lineGeom->setDataVariance(osg::Object::DYNAMIC);
lineGeom->setVertexArray(m_lineVerts);
lineGeom->setColorArray(m_lineColors, osg::Array::BIND_PER_VERTEX);
lineGeom->addPrimitiveSet(
new osg::DrawArrays(osg::PrimitiveSet::LINES, 0, m_lineVerts->size()));
lineGeom->getOrCreateStateSet()->setAttribute(new osg::LineWidth(1.5f));
m_lines = new osg::Geode;
m_lines->addDrawable(lineGeom);
m_root->addChild(m_lines);
// ── Spheres — one MatrixTransform per bone ────────────────────────────────
// We move them by updating their matrix, not rebuilding the drawables.
auto sphereGroup = new osg::Group;
m_root->addChild(sphereGroup);
m_sphereXforms.clear();
m_sphereBoneNames.clear();
for (const auto& name : names) {
auto xform = new osg::MatrixTransform;
xform->setName(name);
xform->setDataVariance(osg::Object::DYNAMIC);
auto sphere = new osg::ShapeDrawable(new osg::Sphere(osg::Vec3(), 0.012f));
sphere->setName(name);
sphere->setColor(osg::Vec4(0.3f, 0.9f, 1.f, 0.9f));
auto geode = new osg::Geode;
geode->addDrawable(sphere);
geode->setName(name);
xform->addChild(geode);
sphereGroup->addChild(xform);
m_sphereXforms.push_back(xform);
m_sphereBoneNames.push_back(name);
}
m_geometryBuilt = true;
std::cout << "[bones] Built overlay for " << names.size() << " bones.\n";
}
// ─────────────────────────────────────────────────────────────────────────────
void BoneSelector::updatePositions() {
if (!m_poseMgr) return;
// names accessed via m_sphereBoneNames
bool selChanged = (m_selected != m_prevSelected);
m_prevSelected = m_selected;
// Update line vertices and sphere transforms
int lineIdx = 0;
for (unsigned i = 0; i < m_sphereBoneNames.size() && i < m_sphereXforms.size(); ++i) {
const std::string& name = m_sphereBoneNames[i];
osg::Vec3 pos = m_poseMgr->getBoneWorldPos(name);
// Move sphere via transform
m_sphereXforms[i]->setMatrix(osg::Matrix::translate(pos));
// Update selection colour if changed
if (selChanged) {
bool isSel = (name == m_selected);
if (auto* geode = dynamic_cast<osg::Geode*>(
m_sphereXforms[i]->getChild(0))) {
if (auto* sd = dynamic_cast<osg::ShapeDrawable*>(
geode->getDrawable(0))) {
sd->setColor(isSel
? osg::Vec4(1.f, 0.9f, 0.f, 1.f)
: osg::Vec4(0.3f, 0.9f, 1.f, 0.9f));
}
}
}
// Line: parent → this bone
std::string parent = m_poseMgr->boneParent(name);
if (!parent.empty() && lineIdx + 1 < (int)m_lineVerts->size()) {
osg::Vec3 parentPos = m_poseMgr->getBoneWorldPos(parent);
bool isSel = (name == m_selected || parent == m_selected);
(*m_lineVerts)[lineIdx] = parentPos;
(*m_lineVerts)[lineIdx+1] = pos;
osg::Vec4 col = isSel
? osg::Vec4(1.f, 0.8f, 0.f, 1.f)
: osg::Vec4(0.4f, 0.8f, 1.f, 0.7f);
(*m_lineColors)[lineIdx] = col;
(*m_lineColors)[lineIdx+1] = col;
lineIdx += 2;
}
}
// Dirty line arrays
if (m_lineVerts) m_lineVerts->dirty();
if (m_lineColors) m_lineColors->dirty();
if (m_lines && m_lines->getNumDrawables() > 0)
m_lines->getDrawable(0)->dirtyBound();
}
// ─────────────────────────────────────────────────────────────────────────────
void BoneSelector::updateVisualization() {
if (!m_poseMgr || !m_poseMgr->isInitialized() || !m_visible) {
if (m_root) m_root->setNodeMask(0);
return;
}
m_root->setNodeMask(~0u);
if (!m_geometryBuilt) buildInitialGeometry();
updatePositions();
}
// ─────────────────────────────────────────────────────────────────────────────
std::string BoneSelector::pick(float mouseX, float mouseY,
osgViewer::Viewer* viewer) {
if (!viewer || !m_root) return {};
osg::ref_ptr<osgUtil::LineSegmentIntersector> intersector =
new osgUtil::LineSegmentIntersector(
osgUtil::Intersector::WINDOW, mouseX, mouseY);
osgUtil::IntersectionVisitor iv(intersector);
m_root->accept(iv);
if (!intersector->containsIntersections()) return {};
const auto& hit = *intersector->getIntersections().begin();
// Walk node path for named node
for (auto it = hit.nodePath.rbegin(); it != hit.nodePath.rend(); ++it) {
const std::string& n = (*it)->getName();
if (!n.empty() && std::find(m_sphereBoneNames.begin(),
m_sphereBoneNames.end(), n)
!= m_sphereBoneNames.end())
return n;
}
if (hit.drawable.valid() && !hit.drawable->getName().empty())
return hit.drawable->getName();
return {};
}
// ─────────────────────────────────────────────────────────────────────────────
void BoneSelector::setSelected(const std::string& boneName) {
m_selected = boneName;
}
void BoneSelector::setVisible(bool v) {
m_visible = v;
if (m_root) m_root->setNodeMask(v ? ~0u : 0u);
}

View File

@@ -1,6 +1,8 @@
#include "ImGuiLayer.h"
#include "MorphManager.h"
#include "AppConfig.h"
#include "PoseManager.h"
#include "BoneSelector.h"
#include <iostream>
#include <algorithm>
@@ -266,6 +268,10 @@ void ImGuiLayer::renderPanel() {
renderTransformTab();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Pose")) {
renderPoseTab();
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
@@ -413,7 +419,6 @@ void ImGuiLayer::renderShaderTab() {
// ── Transform tab ─────────────────────────────────────────────────────────────
void ImGuiLayer::renderTransformTab() {
ImGui::Spacing();
ImGui::TextDisabled("Model scale:");
ImGui::Spacing();
@@ -482,4 +487,175 @@ void ImGuiLayer::renderTransformTab() {
if (active) ImGui::PopStyleColor();
col = (col + 1) % 3;
}
}
// ── Pose tab ──────────────────────────────────────────────────────────────────
void ImGuiLayer::renderPoseTab() {
ImGui::Spacing();
// ── Enable toggle ─────────────────────────────────────────────────────────
if (ImGui::Checkbox("Enable Pose Mode", &m_poseEnabled)) {
if (onPoseModeToggle) onPoseModeToggle(m_poseEnabled);
}
ImGui::SameLine();
ImGui::TextDisabled("(?)");
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Click bones in viewport to select.\n"
"Adjust rotation/translation below.");
if (!m_poseEnabled) {
ImGui::Spacing();
ImGui::TextDisabled("Enable pose mode to edit bones.");
return;
}
if (!m_poseMgr || !m_poseMgr->isInitialized()) {
ImGui::TextDisabled("No skeleton loaded.");
return;
}
// ── Skeleton visibility ───────────────────────────────────────────────────
if (ImGui::Checkbox("Show Skeleton", &m_bonesVisible)) {
if (onBoneVisToggle) onBoneVisToggle(m_bonesVisible);
}
float bw = ImGui::GetContentRegionAvail().x;
if (ImGui::Button("Reset All Bones", ImVec2(bw, 28.f)))
m_poseMgr->resetAll();
ImGui::Separator();
ImGui::Spacing();
// ── Selected bone gizmo ───────────────────────────────────────────────────
if (!m_selectedBone.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.85f, 0.3f, 1.f));
ImGui::Text("Selected: %s", m_selectedBone.c_str());
ImGui::PopStyleColor();
// pose fetched per-control below
// Sync euler display from current pose quat
// Convert quat → euler (degrees) on first selection or change
// (changed flag removed — pose applied immediately via callback)
ImGui::Spacing();
ImGui::TextUnformatted("Rotation (degrees):");
ImGui::SetNextItemWidth(-1.f);
if (ImGui::SliderFloat3("##rot", m_boneEuler, -180.f, 180.f)) {
// Convert euler degrees to quaternion
float rx = m_boneEuler[0] * M_PI / 180.f;
float ry = m_boneEuler[1] * M_PI / 180.f;
float rz = m_boneEuler[2] * M_PI / 180.f;
osg::Quat qx(rx, osg::Vec3(1,0,0));
osg::Quat qy(ry, osg::Vec3(0,1,0));
osg::Quat qz(rz, osg::Vec3(0,0,1));
m_poseMgr->setBoneRotation(m_selectedBone, qz * qy * qx);
}
ImGui::Spacing();
ImGui::TextUnformatted("Translation:");
ImGui::SetNextItemWidth(-1.f);
if (ImGui::SliderFloat3("##trans", m_boneTrans, -0.5f, 0.5f)) {
m_poseMgr->setBoneTranslation(m_selectedBone,
osg::Vec3(m_boneTrans[0], m_boneTrans[1], m_boneTrans[2]));
}
ImGui::Spacing();
if (ImGui::Button("Reset This Bone", ImVec2(-1.f, 26.f))) {
m_poseMgr->resetBone(m_selectedBone);
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
ImGui::Separator();
} else {
ImGui::TextDisabled("Click a bone in the viewport\nor select from the tree below.");
ImGui::Separator();
}
// ── Bone search + tree ────────────────────────────────────────────────────
ImGui::Spacing();
ImGui::SetNextItemWidth(-1.f);
ImGui::InputText("##bonesearch", m_boneSearch, sizeof(m_boneSearch));
ImGui::Spacing();
ImGui::BeginChild("##bonetree", ImVec2(0, 0), false);
std::string filter(m_boneSearch);
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
if (filter.empty()) {
// Show as tree — find root bones (no parent)
const auto& allNames = m_poseMgr->boneNames();
for (const auto& name : allNames) {
if (m_poseMgr->boneParent(name).empty())
renderBoneTree(name, 0);
}
} else {
// Flat filtered list
for (const auto& name : m_poseMgr->boneNames()) {
std::string lname = name;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
if (lname.find(filter) == std::string::npos) continue;
bool sel = (name == m_selectedBone);
if (sel) ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(1.f, 0.85f, 0.3f, 1.f));
if (ImGui::Selectable(name.c_str(), sel)) {
m_selectedBone = name;
if (m_boneSel) m_boneSel->setSelected(name);
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
if (sel) ImGui::PopStyleColor();
}
}
ImGui::EndChild();
}
// ─────────────────────────────────────────────────────────────────────────────
void ImGuiLayer::renderBoneTree(const std::string& boneName, int depth) {
const auto& childMap = m_poseMgr->boneChildren();
auto it = childMap.find(boneName);
bool hasChildren = (it != childMap.end() && !it->second.empty());
bool sel = (boneName == m_selectedBone);
// PushID ensures unique IDs even for bones with identical display names
ImGui::PushID(boneName.c_str());
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow
| ImGuiTreeNodeFlags_SpanAvailWidth;
if (!hasChildren) flags |= ImGuiTreeNodeFlags_Leaf;
if (sel) flags |= ImGuiTreeNodeFlags_Selected;
const auto& pose = m_poseMgr->getBonePose(boneName);
int colorsPushed = 0;
if (pose.modified) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.85f, 0.3f, 1.f));
++colorsPushed;
}
bool open = ImGui::TreeNodeEx("##node", flags, "%s", boneName.c_str());
if (colorsPushed) ImGui::PopStyleColor(colorsPushed);
if (ImGui::IsItemClicked()) {
m_selectedBone = boneName;
if (m_boneSel) m_boneSel->setSelected(boneName);
memset(m_boneEuler, 0, sizeof(m_boneEuler));
memset(m_boneTrans, 0, sizeof(m_boneTrans));
}
if (open) {
if (hasChildren)
for (const auto& child : it->second)
renderBoneTree(child, depth + 1);
ImGui::TreePop();
}
ImGui::PopID();
}

173
src/PoseManager.cpp Normal file
View File

@@ -0,0 +1,173 @@
#include "PoseManager.h"
#include <algorithm>
#include <iostream>
#include <functional>
#include <osg/NodeVisitor>
// ─────────────────────────────────────────────────────────────────────────────
void PoseManager::init(
osgAnimation::Skeleton* skeleton,
const std::unordered_map<std::string,
osg::ref_ptr<osgAnimation::Bone>>& boneMap) {
m_skeleton = skeleton;
m_boneMap = boneMap;
// Run one update traversal so all bones get their initial matrices computed
// from their StackedTransform, then we capture those as bind matrices.
{
osg::NodeVisitor nv(osg::NodeVisitor::UPDATE_VISITOR,
osg::NodeVisitor::TRAVERSE_ALL_CHILDREN);
nv.setTraversalNumber(1);
skeleton->accept(nv);
}
for (auto& [name, bone] : boneMap) {
// Capture bind pose AFTER the first update so it reflects the
// fully-composed local matrix from StackedTransform
m_bindMatrices[name] = bone->getMatrix();
m_poses[name] = BonePose{};
// Remove the UpdateBone callback permanently — we drive all bone
// matrices manually from now on. This prevents the callback from
// ever overwriting our pose values.
bone->setUpdateCallback(nullptr);
}
// Build hierarchy from root bones only
for (auto& [name, bone] : boneMap) {
bool hasParentInMap = false;
for (unsigned i = 0; i < bone->getNumParents(); ++i) {
auto* parent = dynamic_cast<osgAnimation::Bone*>(bone->getParent(i));
if (parent && boneMap.count(parent->getName())) {
hasParentInMap = true;
break;
}
}
if (!hasParentInMap)
buildHierarchy(bone.get(), "");
}
m_boneNames.clear();
for (auto& [name, _] : boneMap)
m_boneNames.push_back(name);
std::sort(m_boneNames.begin(), m_boneNames.end());
m_traversalCount = 1000;
m_initialized = true;
std::cout << "[pose] PoseManager initialized: " << boneMap.size() << " bones.\n";
}
// ─────────────────────────────────────────────────────────────────────────────
void PoseManager::buildHierarchy(osgAnimation::Bone* bone,
const std::string& parentName) {
std::string name = bone->getName();
if (!parentName.empty()) {
m_parents[name] = parentName;
m_children[parentName].push_back(name);
}
for (unsigned i = 0; i < bone->getNumChildren(); ++i) {
auto* child = dynamic_cast<osgAnimation::Bone*>(bone->getChild(i));
if (child) buildHierarchy(child, name);
}
}
// ─────────────────────────────────────────────────────────────────────────────
std::string PoseManager::boneParent(const std::string& name) const {
auto it = m_parents.find(name);
return it != m_parents.end() ? it->second : "";
}
void PoseManager::setBoneRotation(const std::string& name, const osg::Quat& q) {
auto it = m_poses.find(name);
if (it == m_poses.end()) return;
it->second.rotation = q;
it->second.modified = true;
}
void PoseManager::setBoneTranslation(const std::string& name, const osg::Vec3& t) {
auto it = m_poses.find(name);
if (it == m_poses.end()) return;
it->second.translation = t;
it->second.modified = true;
}
void PoseManager::resetBone(const std::string& name) {
auto it = m_poses.find(name);
if (it == m_poses.end()) return;
it->second = BonePose{};
}
void PoseManager::resetAll() {
for (auto& [name, pose] : m_poses) pose = BonePose{};
}
const PoseManager::BonePose& PoseManager::getBonePose(const std::string& name) const {
auto it = m_poses.find(name);
return it != m_poses.end() ? it->second : m_defaultPose;
}
osg::Vec3 PoseManager::getBoneWorldPos(const std::string& name) const {
auto it = m_boneMap.find(name);
if (it == m_boneMap.end()) return {};
return it->second->getMatrixInSkeletonSpace().getTrans();
}
// ─────────────────────────────────────────────────────────────────────────────
void PoseManager::updateSkeletonMatrices() {
// Since all UpdateBone callbacks are removed, we manually propagate
// bone matrices through the hierarchy by walking parent→child.
if (!m_initialized || !m_skeleton) return;
// Walk the skeleton tree and compute skeleton-space matrices
std::function<void(osgAnimation::Bone*, const osg::Matrix&)> propagate =
[&](osgAnimation::Bone* bone, const osg::Matrix& parentSkelMat) {
osg::Matrix skelMat = bone->getMatrix() * parentSkelMat;
bone->setMatrixInSkeletonSpace(skelMat);
for (unsigned i = 0; i < bone->getNumChildren(); ++i) {
auto* child = dynamic_cast<osgAnimation::Bone*>(bone->getChild(i));
if (child) propagate(child, skelMat);
}
};
// Skeleton's own matrix is identity by default
for (unsigned i = 0; i < m_skeleton->getNumChildren(); ++i) {
auto* root = dynamic_cast<osgAnimation::Bone*>(m_skeleton->getChild(i));
if (root) propagate(root, m_skeleton->getMatrix());
}
}
// ─────────────────────────────────────────────────────────────────────────────
void PoseManager::applyPose() {
if (!m_initialized) return;
// Set each bone's local matrix: bind pose + pose delta
for (auto& [name, pose] : m_poses) {
auto boneIt = m_boneMap.find(name);
if (boneIt == m_boneMap.end()) continue;
osgAnimation::Bone* bone = boneIt->second.get();
auto bindIt = m_bindMatrices.find(name);
if (bindIt == m_bindMatrices.end()) continue;
const osg::Matrix& bind = bindIt->second;
if (!pose.modified) {
bone->setMatrix(bind);
continue;
}
osg::Vec3 bindTrans = bind.getTrans();
osg::Quat bindRot; osg::Vec3 sc; osg::Quat so;
bind.decompose(bindTrans, bindRot, sc, so);
bone->setMatrix(
osg::Matrix::scale(sc) *
osg::Matrix::rotate(pose.rotation * bindRot) *
osg::Matrix::translate(bindTrans + pose.translation));
}
}

View File

@@ -12,6 +12,7 @@
#include <osg/CullFace>
#include <osg/BlendFunc>
#include <osg/NodeVisitor>
#include <osgAnimation/RigGeometry>
#include <osg/Geode>
#include <osg/Drawable>
@@ -83,11 +84,15 @@ public:
osg::Drawable* drawable = geode.getDrawable(i);
if (!drawable) continue;
// Use getStateSet() — don't create one if absent, it means
// this drawable truly has no material/texture.
osg::StateSet* ss = drawable->getStateSet();
// RigGeometry stores material/texture on its SOURCE geometry
osg::StateSet* ss = nullptr;
if (auto* rig = dynamic_cast<osgAnimation::RigGeometry*>(drawable)) {
if (rig->getSourceGeometry())
ss = rig->getSourceGeometry()->getStateSet();
}
if (!ss) ss = drawable->getStateSet();
if (!ss) {
// No StateSet at all — create one and mark no texture
ss = drawable->getOrCreateStateSet();
ss->addUniform(new osg::Uniform("u_hasTexture", false),
osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE);

473
src/SkeletonLoader.cpp Normal file
View File

@@ -0,0 +1,473 @@
#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) {
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);
}
// ── 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()] = aiToOsg(bone->mOffsetMatrix);
}
}
if (boneNames.empty()) { std::cerr << "[skel] No bones.\n"; return result; }
std::cout << "[skel] Found " << boneNames.size() << " bones.\n";
// 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;
}