diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..20f3b1c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.16) +project(VRModelViewer VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Debug) +endif() + +find_package(OpenSceneGraph REQUIRED COMPONENTS + osgDB osgGA osgUtil osgViewer osgText osgSim) +find_package(assimp REQUIRED) +find_package(OpenGL REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(IMGUI REQUIRED imgui) + +set(SOURCES + src/main.cpp + src/Application.cpp + src/ModelLoader.cpp + src/MorphManager.cpp + src/ImGuiLayer.cpp + src/SceneBuilder.cpp + src/OrbitManipulator.cpp + src/ShaderManager.cpp + src/AppConfig.cpp +) + +add_executable(${PROJECT_NAME} ${SOURCES}) + +target_include_directories(${PROJECT_NAME} PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${OPENSCENEGRAPH_INCLUDE_DIRS} + ${ASSIMP_INCLUDE_DIRS} + ${IMGUI_INCLUDE_DIRS} +) + +target_link_libraries(${PROJECT_NAME} PRIVATE + ${OPENSCENEGRAPH_LIBRARIES} + ${ASSIMP_LIBRARIES} + ${IMGUI_LIBRARIES} + OpenGL::GL +) + +target_compile_options(${PROJECT_NAME} PRIVATE + $<$:-Wall -Wextra -Wpedantic> + ${IMGUI_CFLAGS_OTHER} +) + +add_custom_target(copy_assets ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/assets ${CMAKE_BINARY_DIR}/assets + COMMENT "Copying assets to build directory" +) +add_dependencies(${PROJECT_NAME} copy_assets) diff --git a/README.md b/README.md index e69de29..751ed23 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,71 @@ +# VR Model Viewer + +Interactive 3-D model viewer built with **OpenSceneGraph** and **Assimp**. +Designed for inspecting PMX (MikuMikuDance), FBX, OBJ and other model formats. + +## Dependencies (already installed on Gentoo) + +| Library | Gentoo package | +|---------|----------------| +| OpenSceneGraph ≥ 3.6 | `dev-games/openscenegraph` | +| Assimp ≥ 5.0 | `media-libs/assimp` | +| CMake ≥ 3.16 | `dev-build/cmake` | +| GCC / Clang (C++17) | `sys-devel/gcc` | + +## Build + +```bash +# From the project root: +cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build --parallel $(nproc) +``` + +Or inside VSCodium press **Ctrl+Shift+B** → *Configure + Build*. + +## Run + +```bash +./build/VRModelViewer path/to/model.pmx +# or +./build/VRModelViewer path/to/model.fbx +``` + +## Controls + +| Input | Action | +|-------|--------| +| **LMB drag** | Orbit camera | +| **MMB drag** | Pan | +| **Scroll** | Zoom (dolly) | +| **R** | Reset camera to default humanoid view | +| **F / Space** | Frame whole scene | +| **S** | Toggle stats overlay (FPS, draw calls) | +| **Esc** | Quit | + +## Project structure + +``` +vr_model_viewer/ +├── CMakeLists.txt +├── include/ +│ ├── Application.h # Top-level app / viewer owner +│ ├── ModelLoader.h # Assimp → OSG conversion +│ ├── SceneBuilder.h # Grid, axes, lights helpers +│ └── OrbitManipulator.h # Tweaked orbit camera +├── src/ +│ ├── main.cpp +│ ├── Application.cpp +│ ├── ModelLoader.cpp +│ ├── SceneBuilder.cpp +│ └── OrbitManipulator.cpp +└── assets/ + └── models/ # drop your .pmx / .fbx files here +``` + +## Roadmap + +- [x] Phase 1 – Basic render pipeline + PMX/FBX loading +- [ ] Phase 2 – Model placement & transform gizmos +- [ ] Phase 3 – Bone / pose inspector +- [ ] Phase 4 – ImGui UI panel +- [ ] Phase 5 – VR headset integration (OpenXR) diff --git a/assets/config.ini b/assets/config.ini new file mode 100644 index 0000000..9e3f9e0 --- /dev/null +++ b/assets/config.ini @@ -0,0 +1,21 @@ +# VR Model Viewer configuration +# Lines starting with # are comments. + +[ui] +# Path to a TTF/OTF font file with CJK coverage. +# Kochi Gothic is a good choice on Gentoo: +# /usr/share/fonts/kochi-substitute/kochi-gothic-subst.ttf +# Leave blank to use ImGui's built-in ASCII-only font. +font_path = /usr/share/fonts/kochi-substitute/kochi-gothic-subst.ttf + +# Font size in pixels +font_size = 14 + +# Starting width of the morph panel in pixels +panel_width = 380 + +[model] +# Initial scale applied to the loaded model. +# FBX exports from Blender are often 100x too large (cm vs m units). +# Try 0.01 if your model appears huge, 1.0 if it looks correct. +scale = 0.01 \ No newline at end of file diff --git a/assets/shaders/cel.frag b/assets/shaders/cel.frag new file mode 100644 index 0000000..d5ea56c --- /dev/null +++ b/assets/shaders/cel.frag @@ -0,0 +1,37 @@ +#version 130 + +varying vec3 v_normalVS; +varying vec3 v_posVS; +varying vec2 v_uv; +varying vec4 v_color; + +uniform sampler2D osg_Sampler0; +uniform bool u_hasTexture; +uniform vec3 u_lightDirVS; +uniform vec3 u_lightColor; +uniform vec3 u_ambientColor; +uniform int u_bands; +uniform float u_bandSharpness; + +float celQuantise(float value, int bands) { + float b = float(bands); + return floor(value * b + 0.5) / b; +} + +void main() { + vec3 N = normalize(v_normalVS); + vec3 L = normalize(u_lightDirVS); + + // Half-lambert so shadows aren't pitch black + float NdL = dot(N, L) * 0.5 + 0.5; + float celVal = celQuantise(NdL, u_bands); + + vec4 baseColor = u_hasTexture + ? texture2D(osg_Sampler0, v_uv) + : v_color; + + vec3 diffuse = baseColor.rgb * u_lightColor * celVal; + vec3 ambient = baseColor.rgb * u_ambientColor; + + gl_FragColor = vec4(ambient + diffuse, baseColor.a); +} diff --git a/assets/shaders/cel.vert b/assets/shaders/cel.vert new file mode 100644 index 0000000..207c8d7 --- /dev/null +++ b/assets/shaders/cel.vert @@ -0,0 +1,19 @@ +#version 130 + +// OSG binds these automatically via Program::addBindAttribLocation +// or via the fixed-function compatibility aliases in core profile. +// Using built-in compatibility varyings is the safest approach with OSG 3.6. + +varying vec3 v_normalVS; +varying vec3 v_posVS; +varying vec2 v_uv; +varying vec4 v_color; + +void main() { + vec4 posVS = gl_ModelViewMatrix * gl_Vertex; + v_posVS = posVS.xyz; + v_normalVS = normalize(gl_NormalMatrix * gl_Normal); + v_uv = gl_MultiTexCoord0.xy; + v_color = gl_Color; + gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; +} diff --git a/assets/shaders/toon.frag b/assets/shaders/toon.frag new file mode 100644 index 0000000..278b8af --- /dev/null +++ b/assets/shaders/toon.frag @@ -0,0 +1,56 @@ +#version 130 + +varying vec3 v_normalVS; +varying vec3 v_posVS; +varying vec2 v_uv; +varying vec4 v_color; + +uniform sampler2D osg_Sampler0; +uniform bool u_hasTexture; +uniform vec3 u_lightDirVS; +uniform vec3 u_lightColor; +uniform vec3 u_ambientColor; +uniform vec3 u_outlineColor; +uniform bool u_outlinePass; +uniform int u_bands; +uniform float u_specularThreshold; +uniform float u_specularIntensity; +uniform float u_rimThreshold; +uniform float u_rimIntensity; +uniform vec3 u_rimColor; + +float celStep(float value, int bands) { + return floor(value * float(bands) + 0.5) / float(bands); +} + +void main() { + if (u_outlinePass) { + gl_FragColor = vec4(u_outlineColor, 1.0); + return; + } + + vec3 N = normalize(v_normalVS); + vec3 L = normalize(u_lightDirVS); + vec3 V = normalize(-v_posVS); + + // Cel diffuse + float NdL = dot(N, L) * 0.5 + 0.5; + float celDif = celStep(NdL, u_bands); + + // Snap specular + vec3 H = normalize(L + V); + float NdH = max(dot(N, H), 0.0); + float spec = step(u_specularThreshold, NdH) * u_specularIntensity; + + // Rim light + float rim = 1.0 - max(dot(N, V), 0.0); + rim = step(u_rimThreshold, rim) * u_rimIntensity; + + vec4 base = u_hasTexture ? texture2D(osg_Sampler0, v_uv) : v_color; + vec3 ambient = base.rgb * u_ambientColor; + vec3 diffuse = base.rgb * u_lightColor * celDif; + vec3 specCol = u_lightColor * spec; + vec3 rimCol = u_rimColor * rim; + + gl_FragColor = vec4(ambient + diffuse + specCol + rimCol, base.a); +} diff --git a/assets/shaders/toon.vert b/assets/shaders/toon.vert new file mode 100644 index 0000000..795eddb --- /dev/null +++ b/assets/shaders/toon.vert @@ -0,0 +1,25 @@ +#version 130 + +uniform bool u_outlinePass; +uniform float u_outlineWidth; + +varying vec3 v_normalVS; +varying vec3 v_posVS; +varying vec2 v_uv; +varying vec4 v_color; + +void main() { + vec3 pos = gl_Vertex.xyz; + + if (u_outlinePass) { + pos += gl_Normal * u_outlineWidth; + } + + vec4 posVS = gl_ModelViewMatrix * vec4(pos, 1.0); + v_posVS = posVS.xyz; + v_normalVS = normalize(gl_NormalMatrix * gl_Normal); + v_uv = gl_MultiTexCoord0.xy; + v_color = gl_Color; + + gl_Position = gl_ModelViewProjectionMatrix * vec4(pos, 1.0); +} diff --git a/include/AppConfig.h b/include/AppConfig.h new file mode 100644 index 0000000..9c1c1bd --- /dev/null +++ b/include/AppConfig.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +/** + * AppConfig + * --------- + * Minimal INI-style config file reader. + * Supports [sections], key = value pairs, and # comments. + * + * Keys are accessed as "section.key", e.g. "ui.font_path". + * Missing keys return a provided default value. + * + * The config file is searched in this order: + * 1. Next to the executable: /assets/config.ini + * 2. Current working dir: assets/config.ini + */ +class AppConfig { +public: + /// Load config. Returns true if a file was found and parsed. + bool load(); + + std::string getString(const std::string& key, + const std::string& defaultVal = "") const; + + float getFloat(const std::string& key, + float defaultVal = 0.f) const; + + int getInt(const std::string& key, + int defaultVal = 0) const; + +private: + std::unordered_map m_values; +}; diff --git a/include/Application.h b/include/Application.h new file mode 100644 index 0000000..a2c3542 --- /dev/null +++ b/include/Application.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ShaderManager.h" +#include "AppConfig.h" + +class MorphManager; +class ImGuiLayer; + +class Application : public osgGA::GUIEventHandler { +public: + Application(); + ~Application(); + + bool init(int width = 1280, int height = 720, + const std::string& title = "VR Model Viewer"); + bool loadModel(const std::string& filepath); + int run(); + + bool handle(const osgGA::GUIEventAdapter& ea, + osgGA::GUIActionAdapter& aa) override; + + void applyPendingShader(); // called by update callback + void applyMorphWeights(); // called by update callback + +private: + void setupLighting(); + void setupGrid(); + void requestShader(const std::string& mode); + void setModelScale(float scale); + + osg::ref_ptr m_viewer; + osg::ref_ptr m_sceneRoot; + osg::ref_ptr m_shaderGroup; + osg::ref_ptr m_modelNode; + osg::ref_ptr m_modelXform; // scale/transform wrapper + + AppConfig m_config; + std::unique_ptr m_shaderMgr; + std::unique_ptr m_morphMgr; + std::unique_ptr m_imguiLayer; + + std::mutex m_shaderMutex; + std::string m_pendingShader; + std::string m_currentShader = "toon"; + bool m_shaderDirty = false; + bool m_reloadShaders = false; +}; \ No newline at end of file diff --git a/include/ImGuiLayer.h b/include/ImGuiLayer.h new file mode 100644 index 0000000..6fce448 --- /dev/null +++ b/include/ImGuiLayer.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +class MorphManager; +class AppConfig; + +class ImGuiLayer { +public: + 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 markGLInitialized(); + + // Callbacks set by Application so ImGui can drive app state + std::function onShaderChange; + std::function onShaderReload; + std::function onScaleChange; + + // 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; } + +private: + void renderMorphTab(); + void renderShaderTab(); + void renderTransformTab(); + + MorphManager* m_morphMgr = nullptr; + const AppConfig* m_cfg = nullptr; + osgViewer::Viewer* m_viewer = nullptr; + osg::ref_ptr m_camera; + + bool m_contextCreated = false; + bool m_glInitialized = false; + float m_panelWidth = 380.f; // wider default so names are visible + + char m_searchBuf[256] = {}; + bool m_showOnlyActive = false; + std::string m_currentShader = "toon"; + float m_scale = 1.0f; + char m_scaleBuf[32] = {}; +}; \ No newline at end of file diff --git a/include/ModelLoader.h b/include/ModelLoader.h new file mode 100644 index 0000000..82db0a1 --- /dev/null +++ b/include/ModelLoader.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +class MorphManager; + +class ModelLoader { +public: + ModelLoader() = default; + + osg::ref_ptr load(const std::string& filepath, + MorphManager* morphMgr = nullptr); + +private: + osg::ref_ptr buildOsgScene(const struct aiScene* scene, + const std::string& baseDir, + MorphManager* morphMgr); +}; \ No newline at end of file diff --git a/include/MorphManager.h b/include/MorphManager.h new file mode 100644 index 0000000..ffb8ce6 --- /dev/null +++ b/include/MorphManager.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +/** + * MorphManager + * ------------ + * Stores morph target data for all meshes in a loaded model and applies + * weighted blends every frame on the CPU. + * + * Usage: + * // At load time (ModelLoader calls these): + * mgr.registerMesh(geom, baseVerts, baseNormals); + * mgr.addTarget(geom, "blink", deltaVerts, deltaNormals); + * + * // Every frame (update callback calls this): + * mgr.applyWeights(); + * + * // From ImGui (slider changed): + * mgr.setWeight("blink", 0.75f); + */ +class MorphManager { +public: + MorphManager() = default; + + // ── Registration (called at load time) ─────────────────────────────────── + + /// Register a geometry's base vertex/normal arrays. + /// Must be called before addTarget() for this geometry. + void registerMesh(osg::Geometry* geom, + osg::ref_ptr baseVerts, + osg::ref_ptr baseNormals); + + /// Add one morph target for a geometry. + /// deltaVerts/deltaNormals are OFFSETS from the base (not absolute positions). + void addTarget(osg::Geometry* geom, + const std::string& name, + osg::ref_ptr deltaVerts, + osg::ref_ptr deltaNormals); + + // ── Weight control ─────────────────────────────────────────────────────── + + void setWeight(const std::string& name, float weight); + float getWeight(const std::string& name) const; + void resetAll(); + + /// Returns all unique morph names across all meshes, sorted. + const std::vector& morphNames() const { return m_morphNames; } + + // ── Per-frame update ───────────────────────────────────────────────────── + + /// Blend all active morphs into each geometry's live vertex/normal arrays + /// and dirty them so OSG re-uploads to the GPU. + void applyWeights(); + +private: + // One morph target contribution for a single geometry + struct Target { + std::string name; + osg::ref_ptr deltaVerts; + osg::ref_ptr deltaNormals; + }; + + // Per-geometry morph data + struct MeshEntry { + osg::Geometry* geom = nullptr; + osg::ref_ptr baseVerts; + osg::ref_ptr baseNormals; + std::vector targets; + }; + + std::vector m_meshes; + std::unordered_map m_weights; // name → 0..1 + std::vector m_morphNames; // sorted unique list + + void rebuildNameList(); +}; diff --git a/include/OrbitManipulator.h b/include/OrbitManipulator.h new file mode 100644 index 0000000..91a633f --- /dev/null +++ b/include/OrbitManipulator.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +/** + * OrbitManipulator + * ---------------- + * Thin subclass of osgGA::OrbitManipulator that tweaks defaults for + * inspecting character models: + * - LMB drag → orbit + * - MMB drag → pan + * - Scroll → dolly + * - F key → frame the whole scene + * - R key → reset to default view + * + * The manipulator targets the scene centre, with a configurable + * initial eye offset. + */ +class OrbitManipulator : public osgGA::OrbitManipulator { +public: + OrbitManipulator(); + + /// Set a comfortable starting position for viewing a humanoid model. + void setDefaultHumanoidView(); + +protected: + bool handleKeyDown(const osgGA::GUIEventAdapter& ea, + osgGA::GUIActionAdapter& aa) override; +}; diff --git a/include/SceneBuilder.h b/include/SceneBuilder.h new file mode 100644 index 0000000..1df5f09 --- /dev/null +++ b/include/SceneBuilder.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +/** + * SceneBuilder + * ------------ + * Factory helpers for common scene-graph elements: + * - reference floor grid + * - ambient + directional lights + * - axes gizmo (X/Y/Z) + */ +class SceneBuilder { +public: + /// Create a flat grid centred at the origin. + /// @param halfSize half-extent of the grid in world units + /// @param divisions number of cells per side + static osg::ref_ptr createGrid(float halfSize = 10.f, + int divisions = 20); + + /// Create a simple 3-axis gizmo (red=X, green=Y, blue=Z). + static osg::ref_ptr createAxes(float length = 1.f); + + /// Build a LightSource node suitable for a warm key light. + static osg::ref_ptr createSunLight(int lightNum = 0); + + /// Build an ambient fill light. + static osg::ref_ptr createAmbientLight(int lightNum = 1); +}; diff --git a/include/ShaderManager.h b/include/ShaderManager.h new file mode 100644 index 0000000..88ca170 --- /dev/null +++ b/include/ShaderManager.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +/** + * ShaderManager + * ------------- + * Loads, caches, and applies GLSL shader programs to OSG nodes. + * + * Supported modes (toggled at runtime via applyTo): + * "flat" – unlit, texture/vertex colour only + * "cel" – quantised diffuse bands + * "toon" – cel + hard specular + rim light + outline shell + * + * Shaders are loaded from `shaderDir` (default: assets/shaders/). + * Editing the .glsl files and calling reload() hot-reloads them. + */ +class ShaderManager { +public: + explicit ShaderManager(const std::string& shaderDir = "assets/shaders"); + + /// Apply a named shader mode to `node`'s StateSet. + /// Also sets sensible default uniforms. + void applyTo(osg::Node* node, const std::string& mode); + + /// Re-read all .glsl files from disk (call after editing shaders). + void reload(); + + /// Update the light direction uniform on an already-shaded node + /// (call when the light moves). `dirVS` should be in view space. + static void setLightDir(osg::StateSet* ss, const osg::Vec3f& dirVS); + +private: + osg::ref_ptr buildProgram(const std::string& vertFile, + const std::string& fragFile); + + void setCommonUniforms(osg::StateSet* ss); + void setCelUniforms (osg::StateSet* ss); + void setToonUniforms (osg::StateSet* ss); + + std::string m_shaderDir; + + // Cache: mode name → compiled program + std::unordered_map> m_programs; +};