This commit is contained in:
2026-03-15 22:03:30 -04:00
parent cea633d5ba
commit 6eca497b1f
16 changed files with 649 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

57
CMakeLists.txt Normal file
View File

@@ -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
$<$<CXX_COMPILER_ID:GNU,Clang>:-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)

View File

@@ -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)

21
assets/config.ini Normal file
View File

@@ -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

37
assets/shaders/cel.frag Normal file
View File

@@ -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);
}

19
assets/shaders/cel.vert Normal file
View File

@@ -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;
}

56
assets/shaders/toon.frag Normal file
View File

@@ -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);
}

25
assets/shaders/toon.vert Normal file
View File

@@ -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);
}

35
include/AppConfig.h Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <unordered_map>
/**
* 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: <exeDir>/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<std::string, std::string> m_values;
};

59
include/Application.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
#include <string>
#include <memory>
#include <mutex>
#include <osgViewer/Viewer>
#include <osg/ref_ptr>
#include <osg/Group>
#include <osg/Node>
#include <osg/NodeCallback>
#include <osg/MatrixTransform>
#include <osgGA/GUIEventHandler>
#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<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
AppConfig m_config;
std::unique_ptr<ShaderManager> m_shaderMgr;
std::unique_ptr<MorphManager> m_morphMgr;
std::unique_ptr<ImGuiLayer> m_imguiLayer;
std::mutex m_shaderMutex;
std::string m_pendingShader;
std::string m_currentShader = "toon";
bool m_shaderDirty = false;
bool m_reloadShaders = false;
};

51
include/ImGuiLayer.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <functional>
#include <osg/Camera>
#include <osgViewer/Viewer>
#include <osgGA/GUIEventAdapter>
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<void(const std::string&)> onShaderChange;
std::function<void()> onShaderReload;
std::function<void(float)> 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<osg::Camera> 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] = {};
};

20
include/ModelLoader.h Normal file
View File

@@ -0,0 +1,20 @@
#pragma once
#include <string>
#include <osg/ref_ptr>
#include <osg/Node>
class MorphManager;
class ModelLoader {
public:
ModelLoader() = default;
osg::ref_ptr<osg::Node> load(const std::string& filepath,
MorphManager* morphMgr = nullptr);
private:
osg::ref_ptr<osg::Node> buildOsgScene(const struct aiScene* scene,
const std::string& baseDir,
MorphManager* morphMgr);
};

83
include/MorphManager.h Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
#include <string>
#include <vector>
#include <unordered_map>
#include <osg/ref_ptr>
#include <osg/Geometry>
#include <osg/Array>
/**
* 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<osg::Vec3Array> baseVerts,
osg::ref_ptr<osg::Vec3Array> 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<osg::Vec3Array> deltaVerts,
osg::ref_ptr<osg::Vec3Array> 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<std::string>& 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<osg::Vec3Array> deltaVerts;
osg::ref_ptr<osg::Vec3Array> deltaNormals;
};
// Per-geometry morph data
struct MeshEntry {
osg::Geometry* geom = nullptr;
osg::ref_ptr<osg::Vec3Array> baseVerts;
osg::ref_ptr<osg::Vec3Array> baseNormals;
std::vector<Target> targets;
};
std::vector<MeshEntry> m_meshes;
std::unordered_map<std::string, float> m_weights; // name → 0..1
std::vector<std::string> m_morphNames; // sorted unique list
void rebuildNameList();
};

View File

@@ -0,0 +1,29 @@
#pragma once
#include <osgGA/OrbitManipulator>
/**
* 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;
};

34
include/SceneBuilder.h Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
#include <osg/ref_ptr>
#include <osg/Group>
#include <osg/Geode>
#include <osg/Geometry>
#include <osg/Light>
#include <osg/LightSource>
/**
* 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<osg::Geode> createGrid(float halfSize = 10.f,
int divisions = 20);
/// Create a simple 3-axis gizmo (red=X, green=Y, blue=Z).
static osg::ref_ptr<osg::Geode> createAxes(float length = 1.f);
/// Build a LightSource node suitable for a warm key light.
static osg::ref_ptr<osg::LightSource> createSunLight(int lightNum = 0);
/// Build an ambient fill light.
static osg::ref_ptr<osg::LightSource> createAmbientLight(int lightNum = 1);
};

51
include/ShaderManager.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <unordered_map>
#include <osg/ref_ptr>
#include <osg/Program>
#include <osg/Node>
#include <osg/StateSet>
/**
* 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<osg::Program> 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<std::string, osg::ref_ptr<osg::Program>> m_programs;
};