This commit is contained in:
2026-03-15 22:02:24 -04:00
parent d46c6ffc4f
commit cea633d5ba
4 changed files with 1249 additions and 0 deletions

217
src/Application.cpp Normal file
View File

@@ -0,0 +1,217 @@
#include <algorithm>
#include "Application.h"
#include "ModelLoader.h"
#include "SceneBuilder.h"
#include "OrbitManipulator.h"
#include "MorphManager.h"
#include "AppConfig.h"
#include "ImGuiLayer.h"
#include <iostream>
#include <mutex>
#include <osg/Group>
#include <osg/StateSet>
#include <osg/CullFace>
#include <osg/Program>
#include <osg/NodeCallback>
#include <osg/MatrixTransform>
#include <osgViewer/ViewerEventHandlers>
// ── 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<ShaderManager>("assets/shaders"))
, m_morphMgr (std::make_unique<MorphManager>())
{
m_config.load();
m_imguiLayer = std::make_unique<ImGuiLayer>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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));
}

485
src/ImGuiLayer.cpp Normal file
View File

@@ -0,0 +1,485 @@
#include "ImGuiLayer.h"
#include "MorphManager.h"
#include "AppConfig.h"
#include <iostream>
#include <algorithm>
#include <string>
#include <cstdio>
#include <cmath>
#include <imgui/imgui.h>
#include <imgui/imgui_impl_opengl3.h>
#include <osg/RenderInfo>
#include <osg/GraphicsContext>
#include <osg/Viewport>
#include <osgViewer/Viewer>
#include <osgGA/GUIEventAdapter>
// ── 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<float>(vp->width())
: (gc && gc->getTraits() ? static_cast<float>(gc->getTraits()->width) : 0.f);
float h = vp ? static_cast<float>(vp->height())
: (gc && gc->getTraits() ? static_cast<float>(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<unsigned int>(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<float>(w), static_cast<float>(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<const char*, float> 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;
}
}

301
src/ModelLoader.cpp Normal file
View File

@@ -0,0 +1,301 @@
#include "ModelLoader.h"
#include "MorphManager.h"
#include <iostream>
#include <filesystem>
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>
#include <osg/Geode>
#include <osg/Geometry>
#include <osg/Array>
#include <osg/PrimitiveSet>
#include <osg/Material>
#include <osg/Texture2D>
#include <osg/BlendFunc>
#include <osg/StateSet>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
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<osg::Texture2D> loadTexture(const std::string& path) {
osg::ref_ptr<osg::Image> 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<osg::Geode> 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<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});
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<osg::Texture2D> 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<osg::Vec3Array*>(geom->getVertexArray());
auto* nArr = dynamic_cast<osg::Vec3Array*>(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<osg::Vec3Array> 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<osg::Group> 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<osg::Node> 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<osg::Node> ModelLoader::buildOsgScene(const aiScene* scene,
const std::string& baseDir,
MorphManager* morphMgr) {
return convertNode(scene->mRootNode, scene, baseDir, morphMgr);
}

246
src/ShaderManager.cpp Normal file
View File

@@ -0,0 +1,246 @@
#include "ShaderManager.h"
#include <iostream>
#include <fstream>
#include <sstream>
#include <filesystem>
#include <unistd.h>
#include <osg/Shader>
#include <osg/Uniform>
#include <osg/Texture2D>
#include <osg/CullFace>
#include <osg/BlendFunc>
#include <osg/NodeVisitor>
#include <osg/Geode>
#include <osg/Drawable>
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: <exeDir>/assets/shaders then <exeDir>/../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<osg::Node&>(geode));
}
};
// ─────────────────────────────────────────────────────────────────────────────
ShaderManager::ShaderManager(const std::string& shaderDir)
: m_shaderDir(resolveShaderDir(shaderDir))
{}
// ─────────────────────────────────────────────────────────────────────────────
osg::ref_ptr<osg::Program> 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<osg::Program> 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);
}