diff --git a/src/Application.cpp b/src/Application.cpp new file mode 100644 index 0000000..1289c27 --- /dev/null +++ b/src/Application.cpp @@ -0,0 +1,217 @@ +#include +#include "Application.h" +#include "ModelLoader.h" +#include "SceneBuilder.h" +#include "OrbitManipulator.h" +#include "MorphManager.h" +#include "AppConfig.h" +#include "ImGuiLayer.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// ── Update callback ─────────────────────────────────────────────────────────── + +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 + traverse(node, nv); + } +private: + Application* m_app; +}; + +// ───────────────────────────────────────────────────────────────────────────── + +Application::Application() + : m_shaderMgr(std::make_unique("assets/shaders")) + , m_morphMgr (std::make_unique()) +{ + m_config.load(); + m_imguiLayer = std::make_unique(m_morphMgr.get(), &m_config); +} + +Application::~Application() = default; + +// ───────────────────────────────────────────────────────────────────────────── + +bool Application::init(int width, int height, const std::string& title) { + m_viewer = new osgViewer::Viewer; + m_viewer->setUpViewInWindow(50, 50, width, height); + + m_sceneRoot = new osg::Group; + m_sceneRoot->setName("SceneRoot"); + m_sceneRoot->getOrCreateStateSet() + ->setMode(GL_CULL_FACE, osg::StateAttribute::OFF); + + setupLighting(); + setupGrid(); + + m_shaderGroup = new osg::Group; + m_shaderGroup->setName("ShaderGroup"); + m_shaderGroup->setUpdateCallback(new AppUpdateCallback(this)); + m_sceneRoot->addChild(m_shaderGroup); + + auto* manip = new OrbitManipulator; + manip->setDefaultHumanoidView(); + m_viewer->setCameraManipulator(manip); + + m_viewer->addEventHandler(new osgViewer::StatsHandler); + m_viewer->addEventHandler(new osgViewer::WindowSizeHandler); + m_viewer->addEventHandler(this); + + m_viewer->setSceneData(m_sceneRoot); + m_viewer->realize(); + + // ImGui must init AFTER realize() (needs a live GL context) + m_imguiLayer->init(m_viewer.get()); + + // Wire shader callbacks so ImGui can drive shader switching + m_imguiLayer->onShaderChange = [this](const std::string& mode) { + requestShader(mode); + }; + m_imguiLayer->onScaleChange = [this](float s) { + setModelScale(s); + }; + m_imguiLayer->setInitialScale(m_config.getFloat("model.scale", 1.0f)); + m_imguiLayer->onShaderReload = [this]() { + std::lock_guard lock(m_shaderMutex); + m_reloadShaders = true; + m_shaderDirty = true; + m_pendingShader = m_currentShader; + }; + m_imguiLayer->setCurrentShader(m_currentShader); + + { + osgViewer::Viewer::Windows windows; + m_viewer->getWindows(windows); + for (auto* w : windows) w->setWindowName(title); + } + + std::cout << "[app] Window " << width << "x" << height + << " - \"" << title << "\" ready.\n"; + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── + +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; + + // Wrap model in a transform so scale/position can be adjusted at runtime + 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(); + + std::cout << "[app] Model loaded. Morphs: " + << m_morphMgr->morphNames().size() << "\n" + << " 1=flat 2=cel 3=toon 4=reload shaders\n"; + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── + +int Application::run() { + // Hook the morph update into the viewer's update traversal. + // We run applyWeights() every frame via a simple per-frame check. + // The actual call is inside the viewer loop below. + while (!m_viewer->done()) { + m_viewer->frame(); // update traversal (callback above) handles morphs + } + return 0; +} + +// ───────────────────────────────────────────────────────────────────────────── + +bool Application::handle(const osgGA::GUIEventAdapter& ea, + osgGA::GUIActionAdapter&) { + // Forward to ImGui first — if it wants the event, don't pass to camera + if (m_imguiLayer->handleEvent(ea)) return true; + + if (ea.getEventType() != osgGA::GUIEventAdapter::KEYDOWN) return false; + if (!m_modelNode) return false; + + switch (ea.getKey()) { + case '1': requestShader("flat"); return true; + case '2': requestShader("cel"); return true; + case '3': requestShader("toon"); return true; + case '4': { + std::lock_guard lock(m_shaderMutex); + m_reloadShaders = true; + m_shaderDirty = true; + m_pendingShader = m_currentShader; + return true; + } + default: return false; + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +void Application::applyPendingShader() { + std::string mode; + bool dirty = false, reload = false; + { + std::lock_guard lock(m_shaderMutex); + if (!m_shaderDirty) return; + mode = m_pendingShader; + dirty = m_shaderDirty; + reload = m_reloadShaders; + m_shaderDirty = m_reloadShaders = false; + } + if (!dirty || !m_shaderGroup) return; + if (reload) m_shaderMgr->reload(); + m_currentShader = mode; + m_shaderMgr->applyTo(m_shaderGroup.get(), mode); + m_imguiLayer->setCurrentShader(mode); +} + +void Application::applyMorphWeights() { + if (m_morphMgr) m_morphMgr->applyWeights(); +} + +void Application::setModelScale(float scale) { + if (m_modelXform) { + scale = std::max(0.001f, scale); + m_modelXform->setMatrix(osg::Matrix::scale(scale, scale, scale)); + } +} + +void Application::requestShader(const std::string& mode) { + std::lock_guard lock(m_shaderMutex); + m_pendingShader = mode; + m_shaderDirty = true; +} + +void Application::setupLighting() { + m_sceneRoot->addChild(SceneBuilder::createSunLight(0)); + m_sceneRoot->addChild(SceneBuilder::createAmbientLight(1)); + m_sceneRoot->getOrCreateStateSet() + ->setMode(GL_LIGHTING, osg::StateAttribute::ON); +} + +void Application::setupGrid() { + m_sceneRoot->addChild(SceneBuilder::createGrid(10.f, 20)); + m_sceneRoot->addChild(SceneBuilder::createAxes(1.f)); +} \ No newline at end of file diff --git a/src/ImGuiLayer.cpp b/src/ImGuiLayer.cpp new file mode 100644 index 0000000..b8b4fc7 --- /dev/null +++ b/src/ImGuiLayer.cpp @@ -0,0 +1,485 @@ +#include "ImGuiLayer.h" +#include "MorphManager.h" +#include "AppConfig.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +// ── Draw callback ───────────────────────────────────────────────────────────── + +struct ImGuiDrawCallback : public osg::Camera::DrawCallback { + ImGuiLayer* layer; + mutable bool glInitialized = false; + + explicit ImGuiDrawCallback(ImGuiLayer* l) : layer(l) {} + + void operator()(osg::RenderInfo& ri) const override { + osg::GraphicsContext* gc = ri.getCurrentCamera()->getGraphicsContext(); + osg::Viewport* vp = ri.getCurrentCamera()->getViewport(); + + float w = vp ? static_cast(vp->width()) + : (gc && gc->getTraits() ? static_cast(gc->getTraits()->width) : 0.f); + float h = vp ? static_cast(vp->height()) + : (gc && gc->getTraits() ? static_cast(gc->getTraits()->height) : 0.f); + if (w < 1.f || h < 1.f) return; + + if (!glInitialized) { + bool ok = ImGui_ImplOpenGL3_Init(nullptr); + if (!ok) ok = ImGui_ImplOpenGL3_Init("#version 130"); + if (!ok) { std::cerr << "[imgui] GL init failed\n"; return; } + layer->markGLInitialized(); + glInitialized = true; + std::cout << "[imgui] OpenGL backend initialized.\n"; + } + + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(w, h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui::NewFrame(); + layer->renderPanel(); + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── + +ImGuiLayer::ImGuiLayer(MorphManager* morphMgr, const AppConfig* cfg) + : m_morphMgr(morphMgr), m_cfg(cfg) +{} + +ImGuiLayer::~ImGuiLayer() { + if (m_glInitialized) ImGui_ImplOpenGL3_Shutdown(); + if (m_contextCreated) ImGui::DestroyContext(); +} + +void ImGuiLayer::markGLInitialized() { m_glInitialized = true; } + +// ───────────────────────────────────────────────────────────────────────────── + +void ImGuiLayer::init(osgViewer::Viewer* viewer) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + m_contextCreated = true; + + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + + // ── Font ───────────────────────────────────────────────────────────────── + static const ImWchar glyphRanges[] = { + 0x0020, 0x00FF, + 0x3000, 0x30FF, // Hiragana + Katakana + 0x4E00, 0x9FFF, // CJK common kanji + 0xFF00, 0xFFEF, + 0, + }; + + std::string fontPath = m_cfg ? m_cfg->getString("ui.font_path") : std::string(); + float fontSize = m_cfg ? m_cfg->getFloat("ui.font_size", 14.f) : 14.f; + m_panelWidth = m_cfg ? m_cfg->getFloat("ui.panel_width", 380.f) : 380.f; + + bool fontLoaded = false; + if (!fontPath.empty()) { + ImFont* f = io.Fonts->AddFontFromFileTTF( + fontPath.c_str(), fontSize, nullptr, glyphRanges); + if (f) { + std::cout << "[imgui] Font: " << fontPath << "\n"; + fontLoaded = true; + } else { + std::cerr << "[imgui] Failed to load font: " << fontPath + << "\n Check ui.font_path in assets/config.ini\n"; + } + } + if (!fontLoaded) { + std::cout << "[imgui] Using built-in font (ASCII only).\n"; + io.Fonts->AddFontDefault(); + } + + // ── Style ──────────────────────────────────────────────────────────────── + ImGui::StyleColorsDark(); + ImGuiStyle& style = ImGui::GetStyle(); + style.WindowRounding = 8.f; + style.FrameRounding = 4.f; + style.TabRounding = 4.f; + style.ScrollbarRounding = 4.f; + style.GrabRounding = 4.f; + style.WindowPadding = ImVec2(10, 10); + style.ItemSpacing = ImVec2(8, 5); + style.WindowMinSize = ImVec2(220.f, 200.f); + + auto& c = style.Colors; + c[ImGuiCol_TitleBg] = ImVec4(0.18f, 0.12f, 0.28f, 1.f); + c[ImGuiCol_TitleBgActive] = ImVec4(0.28f, 0.18f, 0.45f, 1.f); + c[ImGuiCol_Tab] = ImVec4(0.18f, 0.12f, 0.28f, 1.f); + c[ImGuiCol_TabHovered] = ImVec4(0.45f, 0.30f, 0.70f, 1.f); + c[ImGuiCol_TabActive] = ImVec4(0.35f, 0.22f, 0.56f, 1.f); + c[ImGuiCol_TabUnfocused] = ImVec4(0.14f, 0.09f, 0.22f, 1.f); + c[ImGuiCol_TabUnfocusedActive]= ImVec4(0.28f, 0.18f, 0.45f, 1.f); + c[ImGuiCol_ResizeGrip] = ImVec4(0.45f, 0.30f, 0.70f, 0.5f); + c[ImGuiCol_ResizeGripHovered] = ImVec4(0.65f, 0.45f, 0.90f, 0.8f); + c[ImGuiCol_ResizeGripActive] = ImVec4(0.80f, 0.60f, 1.00f, 1.0f); + c[ImGuiCol_Header] = ImVec4(0.28f, 0.18f, 0.45f, 0.6f); + c[ImGuiCol_HeaderHovered] = ImVec4(0.38f, 0.25f, 0.60f, 0.8f); + c[ImGuiCol_SliderGrab] = ImVec4(0.65f, 0.45f, 0.90f, 1.f); + c[ImGuiCol_SliderGrabActive] = ImVec4(0.80f, 0.60f, 1.00f, 1.f); + c[ImGuiCol_FrameBg] = ImVec4(0.12f, 0.08f, 0.18f, 1.f); + c[ImGuiCol_FrameBgHovered] = ImVec4(0.22f, 0.15f, 0.32f, 1.f); + c[ImGuiCol_Button] = ImVec4(0.28f, 0.18f, 0.45f, 1.f); + c[ImGuiCol_ButtonHovered] = ImVec4(0.40f, 0.27f, 0.62f, 1.f); + c[ImGuiCol_CheckMark] = ImVec4(0.80f, 0.60f, 1.00f, 1.f); + c[ImGuiCol_ScrollbarGrab] = ImVec4(0.45f, 0.30f, 0.70f, 1.f); + + // ── Camera ─────────────────────────────────────────────────────────────── + osgViewer::Viewer::Windows windows; + viewer->getWindows(windows); + if (windows.empty()) { std::cerr << "[imgui] No windows found!\n"; return; } + + osg::GraphicsContext* gc = windows[0]; + const auto* traits = gc ? gc->getTraits() : nullptr; + int winW = traits ? traits->width : 1280; + int winH = traits ? traits->height : 720; + + m_camera = new osg::Camera; + m_camera->setName("ImGuiCamera"); + m_camera->setRenderOrder(osg::Camera::POST_RENDER, 100); + m_camera->setClearMask(0); + m_camera->setAllowEventFocus(false); + m_camera->setReferenceFrame(osg::Transform::ABSOLUTE_RF); + m_camera->setViewMatrix(osg::Matrix::identity()); + m_camera->setProjectionMatrix(osg::Matrix::ortho2D(0, winW, 0, winH)); + m_camera->setViewport(0, 0, winW, winH); + m_camera->getOrCreateStateSet()->setMode(GL_DEPTH_TEST, osg::StateAttribute::OFF); + m_camera->setGraphicsContext(gc); + m_camera->setPostDrawCallback(new ImGuiDrawCallback(this)); + + viewer->addSlave(m_camera, false); + m_viewer = viewer; + std::cout << "[imgui] Overlay camera attached (" << winW << "x" << winH << ").\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── + +bool ImGuiLayer::handleEvent(const osgGA::GUIEventAdapter& ea) { + if (!m_contextCreated) return false; + ImGuiIO& io = ImGui::GetIO(); + + switch (ea.getEventType()) { + case osgGA::GUIEventAdapter::MOVE: + case osgGA::GUIEventAdapter::DRAG: + io.AddMousePosEvent(ea.getX(), io.DisplaySize.y - ea.getY()); + break; + case osgGA::GUIEventAdapter::PUSH: + case osgGA::GUIEventAdapter::RELEASE: { + bool down = ea.getEventType() == osgGA::GUIEventAdapter::PUSH; + if (ea.getButton() == osgGA::GUIEventAdapter::LEFT_MOUSE_BUTTON) + io.AddMouseButtonEvent(0, down); + else if (ea.getButton() == osgGA::GUIEventAdapter::RIGHT_MOUSE_BUTTON) + io.AddMouseButtonEvent(1, down); + else if (ea.getButton() == osgGA::GUIEventAdapter::MIDDLE_MOUSE_BUTTON) + io.AddMouseButtonEvent(2, down); + break; + } + case osgGA::GUIEventAdapter::SCROLL: + io.AddMouseWheelEvent(0.f, + ea.getScrollingMotion() == osgGA::GUIEventAdapter::SCROLL_UP + ? 1.f : -1.f); + break; + case osgGA::GUIEventAdapter::KEYDOWN: + case osgGA::GUIEventAdapter::KEYUP: { + bool down = ea.getEventType() == osgGA::GUIEventAdapter::KEYDOWN; + int key = ea.getKey(); + if (key >= 32 && key < 127 && down) + io.AddInputCharacter(static_cast(key)); + if (key == osgGA::GUIEventAdapter::KEY_BackSpace) + io.AddKeyEvent(ImGuiKey_Backspace, down); + if (key == osgGA::GUIEventAdapter::KEY_Delete) + io.AddKeyEvent(ImGuiKey_Delete, down); + if (key == osgGA::GUIEventAdapter::KEY_Return) + io.AddKeyEvent(ImGuiKey_Enter, down); + if (key == osgGA::GUIEventAdapter::KEY_Escape) + io.AddKeyEvent(ImGuiKey_Escape, down); + break; + } + case osgGA::GUIEventAdapter::RESIZE: { + int w = ea.getWindowWidth(), h = ea.getWindowHeight(); + io.DisplaySize = ImVec2(static_cast(w), static_cast(h)); + if (m_camera) { + m_camera->setViewport(0, 0, w, h); + m_camera->setProjectionMatrix(osg::Matrix::ortho2D(0, w, 0, h)); + } + break; + } + default: break; + } + + return io.WantCaptureMouse && + (ea.getEventType() == osgGA::GUIEventAdapter::PUSH || + ea.getEventType() == osgGA::GUIEventAdapter::RELEASE || + ea.getEventType() == osgGA::GUIEventAdapter::MOVE || + ea.getEventType() == osgGA::GUIEventAdapter::DRAG || + ea.getEventType() == osgGA::GUIEventAdapter::SCROLL); +} + +// ── Main panel with tabs ────────────────────────────────────────────────────── + +void ImGuiLayer::renderPanel() { + ImGuiIO& io = ImGui::GetIO(); + + ImGui::SetNextWindowPos( + ImVec2(io.DisplaySize.x - m_panelWidth - 10.f, 10.f), ImGuiCond_Once); + ImGui::SetNextWindowSize( + ImVec2(m_panelWidth, io.DisplaySize.y - 20.f), ImGuiCond_Once); + ImGui::SetNextWindowBgAlpha(0.88f); + ImGui::SetNextWindowSizeConstraints( + ImVec2(220.f, 200.f), + ImVec2(io.DisplaySize.x * 0.9f, io.DisplaySize.y)); + + if (!ImGui::Begin("Model Controls", nullptr, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); return; + } + + m_panelWidth = ImGui::GetWindowWidth(); + + if (ImGui::BeginTabBar("##tabs")) { + if (ImGui::BeginTabItem("Morphs")) { + renderMorphTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Shaders")) { + renderShaderTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Transform")) { + renderTransformTab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + +// ── Morphs tab ──────────────────────────────────────────────────────────────── + +void ImGuiLayer::renderMorphTab() { + if (!m_morphMgr) { ImGui::TextDisabled("No model loaded."); return; } + const auto& names = m_morphMgr->morphNames(); + if (names.empty()) { ImGui::TextDisabled("No morphs found."); return; } + + // Toolbar + ImGui::SetNextItemWidth(-90.f); + ImGui::InputText("Search##morph", m_searchBuf, sizeof(m_searchBuf)); + ImGui::SameLine(); + if (ImGui::Button("Reset All")) { + m_morphMgr->resetAll(); + m_searchBuf[0] = '\0'; + } + ImGui::Checkbox("Active only", &m_showOnlyActive); + ImGui::Separator(); + + std::string filter(m_searchBuf); + std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower); + + // Two-column layout: slider | name + // Slider column is fixed at 120px; name gets all the remaining space + const float sliderW = 120.f; + const float btnW = 22.f; + const float nameW = ImGui::GetContentRegionAvail().x - sliderW - btnW + - ImGui::GetStyle().ItemSpacing.x * 3.f; + + int visible = 0; + ImGui::BeginChild("##morphlist", ImVec2(0, 0), false); + + for (const auto& name : names) { + float w = m_morphMgr->getWeight(name); + if (m_showOnlyActive && w < 1e-4f) continue; + if (!filter.empty()) { + std::string lname = name; + std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower); + if (lname.find(filter) == std::string::npos) continue; + } + ++visible; + + bool isActive = (w > 1e-4f); + + // Slider + ImGui::SetNextItemWidth(sliderW); + std::string sliderID = "##s" + name; + if (isActive) + ImGui::PushStyleColor(ImGuiCol_SliderGrab, + ImVec4(1.0f, 0.75f, 0.3f, 1.f)); + if (ImGui::SliderFloat(sliderID.c_str(), &w, 0.f, 1.f)) + m_morphMgr->setWeight(name, w); + if (isActive) ImGui::PopStyleColor(); + + // Reset button + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.1f, 0.1f, 1.f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.f)); + std::string btnID = "x##" + name; + if (ImGui::SmallButton(btnID.c_str())) + m_morphMgr->setWeight(name, 0.f); + ImGui::PopStyleColor(2); + + // Name — clipped to available width + ImGui::SameLine(); + if (isActive) + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.4f, 1.f)); + ImGui::SetNextItemWidth(nameW); + // PushTextWrapPos clips long names cleanly + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + nameW); + ImGui::TextUnformatted(name.c_str()); + ImGui::PopTextWrapPos(); + if (isActive) ImGui::PopStyleColor(); + } + + if (visible == 0) + ImGui::TextDisabled("No morphs match filter."); + + ImGui::EndChild(); +} + +// ── Shaders tab ─────────────────────────────────────────────────────────────── + +void ImGuiLayer::renderShaderTab() { + ImGui::Spacing(); + ImGui::TextDisabled("Select a shading mode:"); + ImGui::Spacing(); + + struct ShaderOption { + const char* id; + const char* label; + const char* desc; + }; + + static const ShaderOption options[] = { + { "flat", "Flat / Unlit", + "Raw texture colours, no lighting.\nUseful for checking UV maps." }, + { "cel", "Cel Shading", + "Quantised diffuse bands.\nClean anime look without outlines." }, + { "toon", "Toon Shading", + "Cel bands + specular highlight\n+ rim light. Full anime style." }, + }; + + for (auto& opt : options) { + bool selected = (m_currentShader == opt.id); + + if (selected) + ImGui::PushStyleColor(ImGuiCol_Button, + ImVec4(0.45f, 0.28f, 0.72f, 1.f)); + + float bw = ImGui::GetContentRegionAvail().x; + if (ImGui::Button(opt.label, ImVec2(bw, 36.f))) { + m_currentShader = opt.id; + if (onShaderChange) onShaderChange(opt.id); + } + + if (selected) ImGui::PopStyleColor(); + + // Description text, indented + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.7f, 1.f)); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.f); + ImGui::TextUnformatted(opt.desc); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + + ImGui::Separator(); + ImGui::Spacing(); + + float bw = ImGui::GetContentRegionAvail().x; + if (ImGui::Button("Reload Shaders from Disk", ImVec2(bw, 30.f))) { + if (onShaderReload) onShaderReload(); + } + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.6f, 1.f)); + ImGui::TextWrapped("Reloads GLSL files without recompiling.\n" + "Edit assets/shaders/*.vert / *.frag\nthen click."); + ImGui::PopStyleColor(); +} + +// ── Transform tab ───────────────────────────────────────────────────────────── + +void ImGuiLayer::renderTransformTab() { + ImGui::Spacing(); + ImGui::TextDisabled("Model scale:"); + ImGui::Spacing(); + + // Show current scale value + ImGui::Text("Current: %.4f", m_scale); + ImGui::Spacing(); + + // Text input for scale + // Pre-fill the buffer with the current value if it's empty + if (m_scaleBuf[0] == '\0') + snprintf(m_scaleBuf, sizeof(m_scaleBuf), "%.4f", m_scale); + + ImGui::SetNextItemWidth(-1.f); + bool entered = ImGui::InputText("##scale", m_scaleBuf, sizeof(m_scaleBuf), + ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CharsDecimal); + + ImGui::Spacing(); + float bw = ImGui::GetContentRegionAvail().x; + bool clicked = ImGui::Button("Apply Scale", ImVec2(bw, 30.f)); + + if (entered || clicked) { + try { + float parsed = std::stof(std::string(m_scaleBuf)); + if (parsed > 0.f) { + m_scale = parsed; + if (onScaleChange) onScaleChange(m_scale); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1,0.3f,0.3f,1)); + ImGui::TextUnformatted("Scale must be > 0"); + ImGui::PopStyleColor(); + } + } catch (...) { + // non-numeric input — just ignore + } + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + ImGui::TextDisabled("Quick presets:"); + ImGui::Spacing(); + + // Common scale presets in a 3-column grid + const std::pair presets[] = { + {"0.01x", 0.01f}, {"0.1x", 0.1f}, {"0.5x", 0.5f}, + {"1x", 1.0f}, {"2x", 2.0f}, {"10x", 10.0f}, + }; + + int col = 0; + for (auto& [label, val] : presets) { + if (col > 0) ImGui::SameLine(); + float colW = (ImGui::GetContentRegionAvail().x + + ImGui::GetStyle().ItemSpacing.x * (2 - col)) / (3 - col); + + bool active = std::abs(m_scale - val) < 1e-4f; + if (active) + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.28f, 0.72f, 1.f)); + + if (ImGui::Button(label, ImVec2(colW, 28.f))) { + m_scale = val; + snprintf(m_scaleBuf, sizeof(m_scaleBuf), "%.4f", m_scale); + if (onScaleChange) onScaleChange(m_scale); + } + + if (active) ImGui::PopStyleColor(); + col = (col + 1) % 3; + } +} \ No newline at end of file diff --git a/src/ModelLoader.cpp b/src/ModelLoader.cpp new file mode 100644 index 0000000..d9e47fe --- /dev/null +++ b/src/ModelLoader.cpp @@ -0,0 +1,301 @@ +#include "ModelLoader.h" +#include "MorphManager.h" + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +namespace { + +// Section header morphs like "------EYE------" are not real morphs +bool isSectionHeader(const std::string& name) { + return name.size() >= 4 && + name.front() == '-' && name.back() == '-'; +} + +osg::ref_ptr loadTexture(const std::string& path) { + osg::ref_ptr img = osgDB::readImageFile(path); + if (!img) { + std::cerr << "[loader] texture not found: " << path << "\n"; + return {}; + } + auto tex = new osg::Texture2D(img); + tex->setWrap(osg::Texture::WRAP_S, osg::Texture::REPEAT); + tex->setWrap(osg::Texture::WRAP_T, osg::Texture::REPEAT); + tex->setFilter(osg::Texture::MIN_FILTER, osg::Texture::LINEAR_MIPMAP_LINEAR); + tex->setFilter(osg::Texture::MAG_FILTER, osg::Texture::LINEAR); + return tex; +} + +osg::ref_ptr convertMesh(const aiMesh* mesh, + const aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr) { + auto geode = new osg::Geode; + auto geom = new osg::Geometry; + + // ── Base vertices ───────────────────────────────────────────────────────── + 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}); + + // We set a COPY as the live array; the base is kept separately for morphing + auto liveVerts = new osg::Vec3Array(*baseVerts); + geom->setVertexArray(liveVerts); + + // ── Base normals ────────────────────────────────────────────────────────── + osg::ref_ptr 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}); + auto liveNormals = new osg::Vec3Array(*baseNormals); + geom->setNormalArray(liveNormals, osg::Array::BIND_PER_VERTEX); + } + + // ── UVs ─────────────────────────────────────────────────────────────────── + 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); + } + + // ── Vertex colours ──────────────────────────────────────────────────────── + if (mesh->HasVertexColors(0)) { + auto cols = new osg::Vec4Array; + cols->reserve(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) + cols->push_back({mesh->mColors[0][i].r, mesh->mColors[0][i].g, + mesh->mColors[0][i].b, mesh->mColors[0][i].a}); + geom->setColorArray(cols, osg::Array::BIND_PER_VERTEX); + } + + // ── Indices ─────────────────────────────────────────────────────────────── + auto indices = new osg::DrawElementsUInt(osg::PrimitiveSet::TRIANGLES); + indices->reserve(mesh->mNumFaces * 3); + for (unsigned f = 0; f < mesh->mNumFaces; ++f) { + const aiFace& face = mesh->mFaces[f]; + if (face.mNumIndices != 3) continue; + indices->push_back(face.mIndices[0]); + indices->push_back(face.mIndices[1]); + indices->push_back(face.mIndices[2]); + } + // Guard: skip meshes with no valid triangles (avoids front() crash on empty vector) + if (indices->empty()) { + std::cerr << "[loader] Skipping mesh with no valid triangles: " + << mesh->mName.C_Str() << "\n"; + return geode; + } + geom->addPrimitiveSet(indices); + + // ── Material ────────────────────────────────────────────────────────────── + osg::StateSet* ss = geom->getOrCreateStateSet(); + if (mesh->mMaterialIndex < scene->mNumMaterials) { + const aiMaterial* mat = scene->mMaterials[mesh->mMaterialIndex]; + auto osgMat = new osg::Material; + aiColor4D colour; + + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_DIFFUSE, colour)) + osgMat->setDiffuse(osg::Material::FRONT_AND_BACK, + {colour.r, colour.g, colour.b, colour.a}); + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_AMBIENT, colour)) + osgMat->setAmbient(osg::Material::FRONT_AND_BACK, + {colour.r, colour.g, colour.b, colour.a}); + if (AI_SUCCESS == mat->Get(AI_MATKEY_COLOR_SPECULAR, colour)) + osgMat->setSpecular(osg::Material::FRONT_AND_BACK, + {colour.r, colour.g, colour.b, colour.a}); + float shininess = 0.f; + if (AI_SUCCESS == mat->Get(AI_MATKEY_SHININESS, shininess)) + osgMat->setShininess(osg::Material::FRONT_AND_BACK, + std::min(shininess, 128.f)); + ss->setAttribute(osgMat, osg::StateAttribute::ON); + + if (mat->GetTextureCount(aiTextureType_DIFFUSE) > 0) { + aiString texPath; + mat->GetTexture(aiTextureType_DIFFUSE, 0, &texPath); + std::string fullPath = baseDir + "/" + texPath.C_Str(); + for (char& c : fullPath) if (c == '\\') c = '/'; + // Try the path as-is first, then common extension variants + // (FBX often references .png when textures are actually .jpg or .tga) + osg::ref_ptr tex = loadTexture(fullPath); + if (!tex) { + // Try swapping extension + auto swapExt = [](const std::string& p, const std::string& newExt) { + auto dot = p.rfind('.'); + return dot != std::string::npos ? p.substr(0, dot) + newExt : p; + }; + for (auto& ext : {".jpg", ".jpeg", ".png", ".tga", ".bmp"}) { + tex = loadTexture(swapExt(fullPath, ext)); + if (tex) break; + } + } + if (tex) 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)); + } + } + + geode->addDrawable(geom); + + // ── Morph targets ───────────────────────────────────────────────────────── + if (morphMgr && mesh->mNumAnimMeshes > 0) { + // Mark geometry and arrays as DYNAMIC — tells OSG this data changes + // every frame and must not be double-buffered or cached in display lists. + geom->setDataVariance(osg::Object::DYNAMIC); + geom->setUseDisplayList(false); + geom->setUseVertexBufferObjects(true); + + auto* vArr = dynamic_cast(geom->getVertexArray()); + auto* nArr = dynamic_cast(geom->getNormalArray()); + if (vArr) { + vArr->setDataVariance(osg::Object::DYNAMIC); + vArr->setBinding(osg::Array::BIND_PER_VERTEX); + } + if (nArr) { + nArr->setDataVariance(osg::Object::DYNAMIC); + nArr->setBinding(osg::Array::BIND_PER_VERTEX); + } + + morphMgr->registerMesh(geom, baseVerts, baseNormals); + + int registered = 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() || isSectionHeader(name)) continue; + if (am->mNumVertices != mesh->mNumVertices) continue; + + // Compute vertex deltas (animMesh stores ABSOLUTE positions) + auto deltaVerts = new osg::Vec3Array; + deltaVerts->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) { + (*deltaVerts)[i] = osg::Vec3( + am->mVertices[i].x - mesh->mVertices[i].x, + am->mVertices[i].y - mesh->mVertices[i].y, + am->mVertices[i].z - mesh->mVertices[i].z); + } + + // Normal deltas (optional) + osg::ref_ptr deltaNormals; + if (am->mNormals && mesh->HasNormals()) { + deltaNormals = new osg::Vec3Array; + deltaNormals->resize(mesh->mNumVertices); + for (unsigned i = 0; i < mesh->mNumVertices; ++i) { + (*deltaNormals)[i] = osg::Vec3( + 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, deltaVerts, deltaNormals); + ++registered; + } + + if (registered > 0) + std::cout << "[loader] Mesh \"" << mesh->mName.C_Str() + << "\": registered " << registered << " morph targets\n"; + } + + return geode; +} + +osg::ref_ptr convertNode(const aiNode* node, + const aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr) { + const aiMatrix4x4& m = node->mTransformation; + osg::Matrixf mat(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); + + auto xform = new osg::MatrixTransform(mat); + xform->setName(node->mName.C_Str()); + + for (unsigned i = 0; i < node->mNumMeshes; ++i) + xform->addChild(convertMesh(scene->mMeshes[node->mMeshes[i]], + scene, baseDir, morphMgr)); + + for (unsigned i = 0; i < node->mNumChildren; ++i) + xform->addChild(convertNode(node->mChildren[i], scene, baseDir, morphMgr)); + + return xform; +} + +} // namespace + +// ── ModelLoader ─────────────────────────────────────────────────────────────── + +osg::ref_ptr ModelLoader::load(const std::string& filepath, + MorphManager* morphMgr) { + Assimp::Importer importer; + + // NOTE: JoinIdenticalVertices is intentionally omitted — + // it destroys the vertex correspondence that morph targets rely on. + // + // aiProcess_FlipUVs is omitted — FBX exports from Blender already have + // UVs in the correct OpenGL orientation (Y=0 at bottom). Flipping them + // was causing textures to appear mirrored/upside-down. + // If loading a raw PMX via Assimp ever becomes needed, this flag would + // need to be added back conditionally based on file extension. + constexpr unsigned flags = + aiProcess_Triangulate | + aiProcess_GenSmoothNormals | + aiProcess_SortByPType | + aiProcess_ImproveCacheLocality; + + const aiScene* scene = importer.ReadFile(filepath, flags); + if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { + std::cerr << "[loader] Assimp error: " << importer.GetErrorString() << "\n"; + return {}; + } + + std::cout << "[loader] Meshes: " << scene->mNumMeshes + << " Materials: " << scene->mNumMaterials + << " Animations: " << scene->mNumAnimations << "\n"; + + const std::string baseDir = fs::path(filepath).parent_path().string(); + return buildOsgScene(scene, baseDir, morphMgr); +} + +osg::ref_ptr ModelLoader::buildOsgScene(const aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr) { + return convertNode(scene->mRootNode, scene, baseDir, morphMgr); +} \ No newline at end of file diff --git a/src/ShaderManager.cpp b/src/ShaderManager.cpp new file mode 100644 index 0000000..f3cb684 --- /dev/null +++ b/src/ShaderManager.cpp @@ -0,0 +1,246 @@ +#include "ShaderManager.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ── File helpers ────────────────────────────────────────────────────────────── + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) { + std::cerr << "[shader] Cannot open: " << path << "\n"; + return {}; + } + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +// Resolve shader directory relative to the executable, not the cwd. +// Tries: /assets/shaders then /../assets/shaders +static std::string resolveShaderDir(const std::string& hint) { + // 1. Use hint if it already resolves + if (fs::exists(hint)) return fs::canonical(hint).string(); + + // 2. Relative to exe + std::string exePath; + { + char buf[4096] = {}; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) exePath = std::string(buf, n); + } + + if (!exePath.empty()) { + fs::path exeDir = fs::path(exePath).parent_path(); + for (auto candidate : { + exeDir / hint, + exeDir / "assets/shaders", + exeDir / "../assets/shaders"}) { + if (fs::exists(candidate)) { + std::string resolved = fs::canonical(candidate).string(); + std::cout << "[shader] Shader dir: " << resolved << "\n"; + return resolved; + } + } + } + + std::cerr << "[shader] WARNING: could not resolve shader dir from hint \"" + << hint << "\" — shaders will fail to load.\n"; + return hint; +} + +// ── Visitor: stamp u_hasTexture on every Geometry's StateSet ───────────────── +// The shader is applied at the model root, but textures live on per-mesh +// StateSets deeper in the tree. This visitor walks down and sets the uniform +// on each Geode/Geometry so the fragment shader knows whether to sample. + +// Walk every Geode and stamp u_hasTexture on each Drawable's StateSet. +// Textures are stored on osg::Geometry (a Drawable), not on Node — so we +// must reach into the Geode's drawable list directly. +class HasTextureStamper : public osg::NodeVisitor { +public: + int texCount = 0, noTexCount = 0; + + HasTextureStamper() + : osg::NodeVisitor(osg::NodeVisitor::TRAVERSE_ALL_CHILDREN) {} + + void apply(osg::Geode& geode) override { + for (unsigned i = 0; i < geode.getNumDrawables(); ++i) { + 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(); + 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); + ++noTexCount; + continue; + } + + bool hasTex = ss->getTextureAttribute( + 0, osg::StateAttribute::TEXTURE) != nullptr; + ss->addUniform(new osg::Uniform("u_hasTexture", hasTex), + osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE); + if (hasTex) ++texCount; else ++noTexCount; + } + traverse(static_cast(geode)); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── + +ShaderManager::ShaderManager(const std::string& shaderDir) + : m_shaderDir(resolveShaderDir(shaderDir)) +{} + +// ───────────────────────────────────────────────────────────────────────────── + +osg::ref_ptr ShaderManager::buildProgram(const std::string& vertFile, + const std::string& fragFile) { + std::string vertPath = m_shaderDir + "/" + vertFile; + std::string fragPath = m_shaderDir + "/" + fragFile; + + std::string vertSrc = readFile(vertPath); + std::string fragSrc = readFile(fragPath); + + if (vertSrc.empty() || fragSrc.empty()) { + std::cerr << "[shader] Failed to read shader sources for " + << vertFile << " / " << fragFile << "\n"; + return {}; + } + + auto prog = new osg::Program; + prog->setName(vertFile + "+" + fragFile); + + auto vert = new osg::Shader(osg::Shader::VERTEX, vertSrc); + auto frag = new osg::Shader(osg::Shader::FRAGMENT, fragSrc); + vert->setFileName(vertPath); + frag->setFileName(fragPath); + + prog->addShader(vert); + prog->addShader(frag); + + std::cout << "[shader] Built program: " << prog->getName() << "\n"; + return prog; +} + +// ───────────────────────────────────────────────────────────────────────────── + +void ShaderManager::reload() { + m_programs.clear(); + std::cout << "[shader] Programs cleared; will rebuild on next applyTo().\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── + +void ShaderManager::setCommonUniforms(osg::StateSet* ss) { + ss->addUniform(new osg::Uniform("osg_Sampler0", 0)); + + osg::Vec3f lightDir(0.45f, 0.75f, 0.5f); + lightDir.normalize(); + ss->addUniform(new osg::Uniform("u_lightDirVS", lightDir)); + ss->addUniform(new osg::Uniform("u_lightColor", osg::Vec3f(1.0f, 0.95f, 0.85f))); + ss->addUniform(new osg::Uniform("u_ambientColor", osg::Vec3f(0.25f, 0.25f, 0.30f))); + + // Default false; HasTextureStamper overrides per-mesh below + ss->addUniform(new osg::Uniform("u_hasTexture", false)); +} + +void ShaderManager::setCelUniforms(osg::StateSet* ss) { + setCommonUniforms(ss); + ss->addUniform(new osg::Uniform("u_bands", 4)); + ss->addUniform(new osg::Uniform("u_bandSharpness", 0.9f)); +} + +void ShaderManager::setToonUniforms(osg::StateSet* ss) { + setCommonUniforms(ss); + ss->addUniform(new osg::Uniform("u_bands", 4)); + ss->addUniform(new osg::Uniform("u_specularThreshold", 0.92f)); + ss->addUniform(new osg::Uniform("u_specularIntensity", 0.6f)); + ss->addUniform(new osg::Uniform("u_rimThreshold", 0.65f)); + ss->addUniform(new osg::Uniform("u_rimIntensity", 0.4f)); + ss->addUniform(new osg::Uniform("u_rimColor", osg::Vec3f(0.7f, 0.85f, 1.0f))); + ss->addUniform(new osg::Uniform("u_outlineColor", osg::Vec3f(0.05f, 0.02f, 0.08f))); + ss->addUniform(new osg::Uniform("u_outlinePass", false)); + ss->addUniform(new osg::Uniform("u_outlineWidth", 0.025f)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +void ShaderManager::applyTo(osg::Node* node, const std::string& mode) { + if (!node) return; + + osg::StateSet* ss = node->getOrCreateStateSet(); + + // ── Flat: bind an empty program to explicitly disable any active shader. + // removeAttribute() leaves OSG in an undefined state; an empty osg::Program + // forces the fixed-function path reliably. + if (mode == "flat") { + auto emptyProg = new osg::Program; + ss->setAttribute(emptyProg, + osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE); + ss->setMode(GL_LIGHTING, osg::StateAttribute::ON); + std::cout << "[shader] Mode: flat\n"; + return; + } + + // ── Build / fetch compiled program ──────────────────────────────────────── + if (m_programs.find(mode) == m_programs.end()) { + osg::ref_ptr prog; + if (mode == "cel") prog = buildProgram("cel.vert", "cel.frag"); + else if (mode == "toon") prog = buildProgram("toon.vert", "toon.frag"); + else { + std::cerr << "[shader] Unknown mode: " << mode << "\n"; + return; + } + if (!prog) { + std::cerr << "[shader] Build failed for mode \"" << mode + << "\" — falling back to flat.\n"; + applyTo(node, "flat"); + return; + } + m_programs[mode] = prog; + } + + ss->setAttribute(m_programs[mode], + osg::StateAttribute::ON | osg::StateAttribute::OVERRIDE); + ss->setMode(GL_LIGHTING, osg::StateAttribute::OFF); + + // ── Uniforms at root ────────────────────────────────────────────────────── + if (mode == "cel") setCelUniforms(ss); + else if (mode == "toon") setToonUniforms(ss); + + // ── Stamp u_hasTexture on every mesh's StateSet ─────────────────────────── + HasTextureStamper stamper; + node->accept(stamper); + + std::cout << "[shader] Mode: " << mode + << " (textures: " << stamper.texCount + << " no-tex: " << stamper.noTexCount << ")\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── + +/*static*/ +void ShaderManager::setLightDir(osg::StateSet* ss, const osg::Vec3f& dirVS) { + if (auto* u = ss->getUniform("u_lightDirVS")) + u->set(dirVS); +} \ No newline at end of file