diff --git a/.gitmodules b/.gitmodules index c0ad80e..9f61868 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "vendor/github.com/carbonengine/vcpkg-registry"] path = vendor/github.com/carbonengine/vcpkg-registry url = git@github.com:carbonengine/vcpkg-registry.git +[submodule "vendor/github.com/FortAwesome/Font-Awesome"] + path = vendor/github.com/FortAwesome/Font-Awesome + url = https://github.com/FortAwesome/Font-Awesome diff --git a/src/viewer/CMakeLists.txt b/src/viewer/CMakeLists.txt index 0153d0c..e66b165 100644 --- a/src/viewer/CMakeLists.txt +++ b/src/viewer/CMakeLists.txt @@ -23,6 +23,8 @@ set( SHADER_BINARY_TO_SHADER_CACHE ${CMAKE_CURRENT_SOURCE_DIR}/scripts/shaderCac set( SOURCE_SHADER_DIR ${CMAKE_CURRENT_SOURCE_DIR}/assets/shaders/) set( GENERATED_SHADER_DIR ${CMAKE_CURRENT_BINARY_DIR}/GeneratedShaders) set( GLSLC_EXECUTABLE "${VCPKG_INSTALLED_PATH}/tools/shaderc/glslc.exe") +set( FONT_AWESOME_GENERATOR ${CMAKE_CURRENT_SOURCE_DIR}/scripts/fontAwesomeCreator.py) +set( GENERATED_FONTAWESOME_DIR ${CMAKE_CURRENT_BINARY_DIR}/GeneratedFontAwesome) set( ENV{VK_LAYER_PATH} "${VCPKG_INSTALLED_PATH}/bin") set( ENV{VULKAN_SDK} "${VCPKG_INSTALLED_PATH}") @@ -80,6 +82,7 @@ ccp_enable_vs_analizers(CarbonMeshViewer) set(COMPILED_SHADERS "") +# Compile shaders to SPIR-V and generate header files foreach( SHADER_PATH ${SHADERS} ) file( RELATIVE_PATH SHADER_RELATIVE_PATH ${SOURCE_SHADER_DIR} ${SHADER_PATH} ) @@ -100,6 +103,7 @@ foreach( SHADER_PATH ${SHADERS} ) endif() endforeach() +# shader cache generation add_custom_command( OUTPUT "${GENERATED_SHADER_DIR}/generatedShaderCache.h" DEPENDS ${SHADERS} ${COMPILED_SHADERS} ${SHADER_BINARY_TO_SHADER_CACHE} @@ -114,9 +118,23 @@ message(STATUS "generated files: ${COMPILED_SHADERS}") add_custom_target(CompileShaders DEPENDS ${COMPILED_SHADERS}) add_dependencies(CarbonMeshViewer CompileShaders) +# font awesome generation +get_filename_component(FONT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../vendor/github.com/FortAwesome/Font-Awesome" ABSOLUTE) + +add_custom_command( + OUTPUT "${GENERATED_FONTAWESOME_DIR}/faBinary.h" "${GENERATED_FONTAWESOME_DIR}/faLookup.h" + DEPENDS ${FONT_AWESOME_GENERATOR} "${FONT_DIR}/webfonts/fa-solid-900.ttf" "${FONT_DIR}/metadata/icons.json" + + COMMAND ${Python3_EXECUTABLE} ${FONT_AWESOME_GENERATOR} "${FONT_DIR}/webfonts/fa-solid-900.ttf" "${FONT_DIR}/metadata/icons.json" "${GENERATED_FONTAWESOME_DIR}/faBinary.h" "${GENERATED_FONTAWESOME_DIR}/faLookup.h" + COMMENT "Generating Font Awesome header ${GENERATED_FONTAWESOME_DIR}/faBinary.h and ${GENERATED_FONTAWESOME_DIR}/faLookup.h from ${CMAKE_CURRENT_SOURCE_DIR} and ${FONT_DIR}." +) +add_custom_target(CompileFontAwesome DEPENDS "${GENERATED_FONTAWESOME_DIR}/faBinary.h" "${GENERATED_FONTAWESOME_DIR}/faLookup.h") +add_dependencies(CarbonMeshViewer CompileFontAwesome) + target_link_libraries(CarbonMeshViewer PUBLIC CarbonMesh CcpMath glfw Vulkan::Vulkan imgui::imgui tinyfiledialogs::tinyfiledialogs) target_precompile_headers(CarbonMeshViewer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stdafx.h) target_include_directories(CarbonMeshViewer PRIVATE ${GENERATED_SHADER_DIR}) +target_include_directories(CarbonMeshViewer PRIVATE ${GENERATED_FONTAWESOME_DIR}) target_compile_options(CarbonMeshViewer PRIVATE $<$,$>:-fms-extensions>) target_compile_features(CarbonMeshViewer PRIVATE cxx_std_17) diff --git a/src/viewer/appState.cpp b/src/viewer/appState.cpp index 69a4c53..aed1d98 100644 --- a/src/viewer/appState.cpp +++ b/src/viewer/appState.cpp @@ -17,10 +17,13 @@ void AppState::CallStateCallbacks() windowSize.CallCallbacks( *this ); mouseState.CallCallbacks( *this ); cameraTrigger.CallCallbacks( *this ); + cameraFocus.CallCallbacks( *this ); cmfContent.CallCallbacks( *this ); cmfPath.CallCallbacks( *this ); + cmfLoadRequest.CallCallbacks( *this ); exitRequested.CallCallbacks( *this ); + showUi.CallCallbacks( *this ); modelState.CallCallbacks( *this ); } diff --git a/src/viewer/appState.h b/src/viewer/appState.h index 5a87922..bb748a2 100644 --- a/src/viewer/appState.h +++ b/src/viewer/appState.h @@ -148,7 +148,6 @@ struct ModelState State> activeShader{ {} }; State>> availableShaders{ {} }; - State polygonMode{ VK_POLYGON_MODE_FILL }; State currentAnimation{ "" }; State currentAnimationTime{ 0.0f }; @@ -166,7 +165,6 @@ struct ModelState meshes.CallCallbacks( appState ); activeShader.CallCallbacks( appState ); availableShaders.CallCallbacks( appState ); - polygonMode.CallCallbacks( appState ); currentAnimation.CallCallbacks( appState ); currentAnimationTime.CallCallbacks( appState ); boneDebug.CallCallbacks( appState ); @@ -189,12 +187,16 @@ struct AppState // camera State cameraTrigger{ CameraTrigger::CAMERA_TRIGGER_NONE }; + State cameraFocus{ {} }; // cmf State> cmfContent{ nullptr }; State cmfPath{ "" }; + // requests from ui to load cmf data + State cmfLoadRequest{ "" }; State exitRequested{ false }; + State showUi{ true }; // model ModelState modelState{}; diff --git a/src/viewer/application.cpp b/src/viewer/application.cpp index b57e0ce..8db37e9 100644 --- a/src/viewer/application.cpp +++ b/src/viewer/application.cpp @@ -150,6 +150,14 @@ void Application::Initialize() m_renderer->Resize( width, height ); } ); + m_appState.cmfLoadRequest.RegisterCallback( [this]( std::string path, AppState& ) { + if( !path.empty() ) + { + LoadCmfFile( path ); + m_appState.cmfLoadRequest.SetValueNoCallback( "" ); + } + } ); + m_valid = true; } @@ -163,6 +171,7 @@ void Application::Run() while( !glfwWindowShouldClose( m_window ) && !m_appState.exitRequested.GetValue() ) { + m_appState.CallStateCallbacks(); // Poll for and process events @@ -199,6 +208,7 @@ void Application::Run() m_orientationGizmoRenderer->Render( m_appState, m_camera ); m_uiRenderer->Update( m_appState ); + m_uiRenderer->Render( m_appState ); if( m_renderer->EndRender() != VK_SUCCESS ) @@ -241,10 +251,10 @@ void Application::LoadCmfFile( const std::string& path ) else { Log::Info( "Applying model file %s", path.c_str() ); - // load the cmf content from the path and set it in the app state m_appState.cmfPath.SetValue( path ); m_appState.modelState.activeAnimationOwner.SetValue( data ); m_appState.cmfContent.ForceSetValue( data ); + m_sceneRenderer->SetData( data, m_appState ); } } diff --git a/src/viewer/application.h b/src/viewer/application.h index 98e7386..e9f2e03 100644 --- a/src/viewer/application.h +++ b/src/viewer/application.h @@ -7,7 +7,7 @@ #include "rendering/orientationGizmoRenderer.h" #include "rendering/sceneRenderer.h" #include "rendering/renderer.h" -#include "rendering/uiRenderer.h" +#include "rendering/ui/uiRenderer.h" class Application { diff --git a/src/viewer/main.cpp b/src/viewer/main.cpp index 0614cdd..582466f 100644 --- a/src/viewer/main.cpp +++ b/src/viewer/main.cpp @@ -23,7 +23,11 @@ int main( int argc, char** argv ) app.Initialize(); if( argc > 1 ) { - app.LoadCmfFile( argv[1] ); + for( int i = 1; i < argc; ++i ) + { + Log::Info( "Loading file: %s", argv[i] ); + app.LoadCmfFile( argv[i] ); + } } app.Run(); diff --git a/src/viewer/rendering/camera.cpp b/src/viewer/rendering/camera.cpp index 76c65ec..160e37a 100644 --- a/src/viewer/rendering/camera.cpp +++ b/src/viewer/rendering/camera.cpp @@ -11,8 +11,9 @@ void Camera::Initialize( AppState& state ) SetScreenSize( width, height ); } ); - state.cameraTrigger.RegisterCallback( [this]( CameraTrigger trigger, AppState& ) { - HandleCameraTriggerChange( trigger ); + state.cameraTrigger.RegisterCallback( [this]( CameraTrigger trigger, AppState& state ) { + CcpMath::Sphere focusSphere = state.cameraFocus.GetValue(); + HandleCameraTriggerChange( trigger, focusSphere ); } ); state.mouseState.RegisterCallback( [this]( MouseState newMouseState, AppState& ) { @@ -30,17 +31,25 @@ void Camera::Initialize( AppState& state ) SetScreenSize( width, height ); } -void Camera::HandleCameraTriggerChange( CameraTrigger& trigger ) +void Camera::HandleCameraTriggerChange( CameraTrigger& trigger, const CcpMath::Sphere& focusSphere ) { if( trigger == CameraTrigger::CAMERA_TRIGGER_NONE ) { return; } + + CcpMath::Sphere boundingSphere = m_boundingSphere; + if( focusSphere.radius > 0.0f ) + { + boundingSphere = focusSphere; + } + this->LookAt( boundingSphere.center ); + + switch( trigger ) { case CameraTrigger::CAMERA_TRIGGER_FOCUS: - this->LookAt( this->m_boundingSphere.center ); - m_targetZoom = this->m_boundingSphere.radius * DEFAULT_ZOOM_MULTIPLIER; + m_targetZoom = boundingSphere.radius * DEFAULT_ZOOM_MULTIPLIER; break; case CameraTrigger::CAMERA_TRIGGER_LOOK_UP: this->m_targetRotation = RotationQuaternion( 0.0f, -PI / 2.0f, 0.0f ); @@ -101,11 +110,13 @@ void Camera::HandleMouseStateChanged( MouseState& mouseState ) Matrix Camera::GetProjection() const { - const float ABSOLUTE_MIN_NEAR_PLANE = 0.01f; + const float ABSOLUTE_MIN_FAR_PLANE = 1.0f; const float boundingDepth = m_boundingSphere.radius * 2.0f; const float centerDepth = -TransformCoord( m_boundingSphere.center, GetView() ).z; - const float nearPlane = std::max( ABSOLUTE_MIN_NEAR_PLANE, centerDepth - boundingDepth ); - const float farPlane = std::max( nearPlane + ABSOLUTE_MIN_NEAR_PLANE, centerDepth + boundingDepth ); + const float calculatedFar = centerDepth + boundingDepth; + const float farPlane = std::max( ABSOLUTE_MIN_FAR_PLANE, calculatedFar ); + + const float nearPlane = std::max( farPlane / 100.0f, centerDepth - boundingDepth ); return PerspectiveFovMatrix( m_fov, diff --git a/src/viewer/rendering/camera.h b/src/viewer/rendering/camera.h index 102d4dc..c789b50 100644 --- a/src/viewer/rendering/camera.h +++ b/src/viewer/rendering/camera.h @@ -14,7 +14,7 @@ class Camera void Initialize( AppState& state ); - void HandleCameraTriggerChange( CameraTrigger& trigger ); + void HandleCameraTriggerChange( CameraTrigger& trigger, const CcpMath::Sphere& focusSphere ); void HandleMouseStateChanged( MouseState& newMouseState ); diff --git a/src/viewer/rendering/models/primitiveEffects.cpp b/src/viewer/rendering/models/primitiveEffects.cpp index 146e900..832c6be 100644 --- a/src/viewer/rendering/models/primitiveEffects.cpp +++ b/src/viewer/rendering/models/primitiveEffects.cpp @@ -93,8 +93,7 @@ GraphicsEffect PrimitiveEffects::CreateFlatColorEffect( std::shared_ptr( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 2, vertexToBoneMapping.data(), vertexToBoneMapping.size() ); // vertex to bone mapping effect.RegisterStorageBuffer( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 3, 0xFF ); // selected bones effect.RegisterUniformData( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 4, colorInfo ); - effect.SetConfig( config ); - effect.SetShaderName( "flatcolor" ); + effect.Reset( "flatcolor", config ); return effect; } @@ -120,8 +119,7 @@ GraphicsEffect PrimitiveEffects::CreateOrientationEffect( std::shared_ptr( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 0 ); effect.RegisterStorageBuffer( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 1, 0xFF ); - effect.SetConfig( config ); - effect.SetShaderName( "orientationgizmo" ); + effect.Reset( "orientationgizmo", config ); return effect; } diff --git a/src/viewer/rendering/renderable/mesh.cpp b/src/viewer/rendering/renderable/mesh.cpp index 0e3870f..3691a66 100644 --- a/src/viewer/rendering/renderable/mesh.cpp +++ b/src/viewer/rendering/renderable/mesh.cpp @@ -38,21 +38,19 @@ MeshRenderable::MeshRenderable( std::shared_ptr data, const cmf::Mes } } } + + m_polygonMode = m_cmfMesh.topology == cmf::MeshTopology::TriangleList ? VK_POLYGON_MODE_FILL : VK_POLYGON_MODE_POINT; + m_topology = m_cmfMesh.topology == cmf::MeshTopology::TriangleList ? VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST : VK_PRIMITIVE_TOPOLOGY_POINT_LIST; } void MeshRenderable::Initialize( AppState& appState ) { m_prepass.Initialize( appState ); - appState.modelState.polygonMode.RegisterCallback( [this]( VkPolygonMode mode, AppState& appState ) { - auto [name, declaration] = appState.modelState.activeShader.GetValue(); - SetRenderingMode( name, declaration, mode ); - } ); - appState.modelState.activeShader.RegisterCallback( [this]( std::pair shaderInputDeclaration, AppState& appState ) { auto [name, declaration] = shaderInputDeclaration; - SetRenderingMode( name, declaration, appState.modelState.polygonMode.GetValue() ); + SetRenderingMode( name, declaration ); } ); m_meshIndex = appState.modelState.meshes.AddState( [this]( MeshState& meshState ) { @@ -400,24 +398,23 @@ void MeshRenderable::DrawIndexed( GraphicsCommandBuffer& commandBuffer ) } } -VkResult MeshRenderable::SetRenderingMode( std::string shaderName, GraphicsEffectTypes::ShaderInputDeclaration shaderInputDeclaration, VkPolygonMode polygonMode ) +VkResult MeshRenderable::SetRenderingMode( std::string shaderName, GraphicsEffectTypes::ShaderInputDeclaration shaderInputDeclaration ) { auto logicalDevice = m_renderer->GetDevice()->GetLogicalDevice(); - m_polygonMode = polygonMode; m_shaderName = shaderName; CR_RETURN( vkDeviceWaitIdle( logicalDevice ) ); auto config = GraphicsEffectTypes::Config(); config.topology = m_topology; - config.polygonMode = polygonMode; - config.cullMode = ( polygonMode == VK_POLYGON_MODE_FILL ) ? VK_CULL_MODE_BACK_BIT : VK_CULL_MODE_NONE; + config.polygonMode = m_polygonMode; + config.cullMode = ( m_polygonMode == VK_POLYGON_MODE_FILL ) ? VK_CULL_MODE_BACK_BIT : VK_CULL_MODE_NONE; config.inputDeclaration.vertexDeclarations = shaderInputDeclaration.vertexDeclarations; config.inputDeclaration.stride = m_stride; - m_modelEffect.SetShaderName( m_shaderName ); - m_modelEffect.SetConfig( config ); + m_modelEffect.Reset( m_shaderName, config ); + if( !m_modelEffect.IsInitialized() ) { m_modelEffect.RegisterUniformData( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 0 ); @@ -443,8 +440,7 @@ VkResult MeshRenderable::SetRenderingMode( std::string shaderName, GraphicsEffec 3, 0 } }; - m_wireframeEffect.SetShaderName( "wireframeoverlay" ); - m_wireframeEffect.SetConfig( wireframeConfig ); + m_wireframeEffect.Reset( "wireframeoverlay", wireframeConfig ); m_wireframeEffect.RegisterUniformData( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 0 ); m_wireframeEffect.Initialize(); } @@ -469,8 +465,7 @@ GraphicsEffect MeshRenderable::GetAudioOcclusionEffect( std::shared_ptr( VkShaderStageFlagBits::VK_SHADER_STAGE_VERTEX_BIT, 0 ); return effect; } diff --git a/src/viewer/rendering/renderable/mesh.h b/src/viewer/rendering/renderable/mesh.h index 947b7a7..c7a7052 100644 --- a/src/viewer/rendering/renderable/mesh.h +++ b/src/viewer/rendering/renderable/mesh.h @@ -22,7 +22,7 @@ class MeshRenderable void Render( GraphicsCommandBuffer& commandBuffer, const AppState& appState, const Camera& camera ); void RenderDebug( GraphicsCommandBuffer& commandBuffer, const AppState& appState, const Camera& camera ); void PrepareMesh( ComputeCommandBuffer& computeCommandBuffer ); - VkResult SetRenderingMode( std::string shaderName, GraphicsEffectTypes::ShaderInputDeclaration shaderInputDeclaration, VkPolygonMode polygonMode ); + VkResult SetRenderingMode( std::string shaderName, GraphicsEffectTypes::ShaderInputDeclaration shaderInputDeclaration ); void SetAnimation( const cmf::Animation* animation ); void UpdateMeshCurves( float animationTime, const cmf::Animation* animation, AppState& appState ); diff --git a/src/viewer/rendering/sceneRenderer.cpp b/src/viewer/rendering/sceneRenderer.cpp index 43f0ad0..915dc77 100644 --- a/src/viewer/rendering/sceneRenderer.cpp +++ b/src/viewer/rendering/sceneRenderer.cpp @@ -31,10 +31,6 @@ SceneRenderer::~SceneRenderer() VkResult SceneRenderer::Initialize( AppState& state ) { - state.cmfContent.RegisterCallback( [this]( std::shared_ptr content, AppState& appState ) { - this->SetData( content, appState ); - } ); - state.windowSize.RegisterCallback( [this]( std::pair size, AppState& appState ) { auto [width, height] = size; this->m_graphicsCommandBuffer.SetRenderSize( width, height ); diff --git a/src/viewer/rendering/ui/uiAnimationPlayback.cpp b/src/viewer/rendering/ui/uiAnimationPlayback.cpp new file mode 100644 index 0000000..04d4ff2 --- /dev/null +++ b/src/viewer/rendering/ui/uiAnimationPlayback.cpp @@ -0,0 +1,155 @@ +// Copyright © 2026 CCP ehf. + +#include "uiAnimationPlayback.h" +#include + +#include "uiCustomWidgets.h" + +const float ANIMATION_SELECTION_WIDTH = 120.0f; + +void UIAnimationPlayback::Render( AppState& appState ) +{ + UpdateState( appState ); + + const auto [width, height] = appState.windowSize.GetValue(); + + ImGui::SetNextWindowPos( ImVec2( 0, static_cast( height ) - UiConsts::ANIMATION_PLAYER_HEIGHT ), ImGuiCond_Always ); + ImGui::SetNextWindowSize( ImVec2( static_cast( width ), UiConsts::ANIMATION_PLAYER_HEIGHT ), ImGuiCond_Always ); + if( ImGui::Begin( "##animationTitle", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize ) ) + { + // animation selection + std::vector animationNames; + std::vector animationDuration; + const auto& activeAnimationOwner = appState.modelState.activeAnimationOwner.GetValue(); + int32_t selectedAnimationIndex = -1; + + if( activeAnimationOwner != nullptr && !activeAnimationOwner->m_cmfData->animations.empty() ) + { + // add the rest pose + animationNames.push_back( "Rest Pose" ); + animationDuration.push_back( 0.0f ); + const auto& animations = activeAnimationOwner->m_cmfData->animations; + std::for_each( animations.begin(), animations.end(), [&animationNames, &animationDuration]( const cmf::Animation& animation ) { + animationDuration.push_back( animation.duration ); + animationNames.push_back( cmf::ToStdString( animation.name ) ); + } ); + // Map currentAnimation to index: 0 = Rest Pose (""), 1..N = animations[0..N-1] + if( appState.modelState.currentAnimation.GetValue().empty() ) + { + selectedAnimationIndex = 0; + } + else + { + auto it = std::find_if( animations.begin(), animations.end(), [&appState]( const cmf::Animation& animation ) { + return cmf::ToStdString( animation.name ) == appState.modelState.currentAnimation.GetValue(); + } ); + if( it != animations.end() ) + { + selectedAnimationIndex = static_cast( std::distance( animations.begin(), it ) ) + 1; + } + else + { + // couldn't find the animation, reset to rest pose + selectedAnimationIndex = 0; + m_playing = false; + m_currentTime = 0.0f; + m_duration = 0.0f; + appState.modelState.currentAnimation.SetValue( "" ); + appState.modelState.currentAnimationTime.SetValue( m_currentTime ); + } + } + } + + ImGui::BeginDisabled( animationNames.empty() ); + + ImGui::PushItemWidth( ANIMATION_SELECTION_WIDTH ); + ImGui::ComboBox( "##animation", animationNames, selectedAnimationIndex, [&]( int32_t selectedIndex ) { + m_playing = false; + m_currentTime = 0.0f; + m_duration = animationDuration[selectedIndex]; + if( selectedIndex == 0 ) + { + // rest pose + appState.modelState.currentAnimation.SetValue( "" ); + } + else + { + appState.modelState.currentAnimation.SetValue( animationNames[selectedIndex] ); + } + appState.modelState.currentAnimationTime.SetValue( m_currentTime ); + } ); + ImGui::PopItemWidth(); + ImGui::SetItemTooltip( "Select an animation to play" ); + + ImGui::SameLine(); + + // step left + if( ImGui::FontAwesomeButton( ICON_FA_CHEVRON_LEFT, 0 ) ) + { + m_currentTime -= 0.1f; + } + ImGui::SameLine(); + + // play/pause button + if( ImGui::FontAwesomeButton( m_playing ? ICON_FA_PAUSE : ICON_FA_PLAY, 1 ) ) + { + m_playing = !m_playing; + + if( m_currentTime == m_duration ) + { + m_currentTime = 0.0f; + } + } + + ImGui::SameLine(); + + // step right + if( ImGui::FontAwesomeButton( ICON_FA_CHEVRON_RIGHT, 2 ) ) + { + m_currentTime += 0.1f; + } + + ImGui::SameLine(); + bool repeatPressed = m_repeat ? ImGui::FontAwesomeButton( ICON_FA_REPEAT, 3 ) : ImGui::FontAwesomeSlashedButton( ICON_FA_REPEAT, 3 ); + if( repeatPressed ) + { + m_repeat = !m_repeat; + } + ImGui::SetItemTooltip( "Repeat the animation" ); + + ImGui::SameLine(); + float availableWidth = ImGui::GetContentRegionAvail().x; + ImGui::PushItemWidth( availableWidth ); + if( ImGui::SliderFloat( "##playbackSlider", &m_currentTime, 0.0f, m_duration ) ) + { + appState.modelState.currentAnimationTime.SetValue( m_currentTime ); + } + ImGui::SetItemTooltip( "%.3fs / %.3fs", m_currentTime, m_duration ); + ImGui::PopItemWidth(); + + ImGui::EndDisabled(); + ImGui::SameLine(); + } + ImGui::End(); +} + +void UIAnimationPlayback::UpdateState( AppState& appState ) +{ + if( m_playing ) + { + m_currentTime += ImGui::GetIO().DeltaTime; + if( m_currentTime >= m_duration ) + { + if( m_repeat ) + { + m_currentTime = 0.0f; + } + else + { + m_currentTime = m_duration; + m_playing = false; + } + } + } + appState.modelState.currentAnimationTime.SetValue( m_currentTime ); +} \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiAnimationPlayback.h b/src/viewer/rendering/ui/uiAnimationPlayback.h new file mode 100644 index 0000000..e4a9772 --- /dev/null +++ b/src/viewer/rendering/ui/uiAnimationPlayback.h @@ -0,0 +1,21 @@ +// Copyright © 2026 CCP ehf. + +#pragma once + +#include "../../appState.h" + + + +class UIAnimationPlayback +{ +public: + void Render( AppState& appState ); + +private: + void UpdateState( AppState& appState ); + + float m_duration{ 0.0f }; + float m_currentTime{ 0.0f }; + bool m_playing{ false }; + bool m_repeat{ false }; +}; \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiConsts.h b/src/viewer/rendering/ui/uiConsts.h new file mode 100644 index 0000000..c6395b1 --- /dev/null +++ b/src/viewer/rendering/ui/uiConsts.h @@ -0,0 +1,14 @@ +// Copyright © 2026 CCP ehf. + +#pragma once +#include + +namespace UiConsts +{ +const float MENU_BAR_HEIGHT = 18.0f; +const float ANIMATION_PLAYER_HEIGHT = 36.0f; +const float BUTTON_WIDTH = 19.0f; +const float BUTTON_HEIGHT = 19.0f; +const ImVec2 BUTTON_SIZE = ImVec2( BUTTON_WIDTH, BUTTON_HEIGHT ); +const float FONT_AWESOME_SIZE = 13.0f; +} \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiCustomWidgets.cpp b/src/viewer/rendering/ui/uiCustomWidgets.cpp new file mode 100644 index 0000000..72a5489 --- /dev/null +++ b/src/viewer/rendering/ui/uiCustomWidgets.cpp @@ -0,0 +1,111 @@ +// Copyright © 2026 CCP ehf. +#include "uiCustomWidgets.h" + +#include +#include +#include +#include + +#include "uiConsts.h" + +// ImGui is using a lot of variadic functions for text formatting, so we disable the cppcoreguidelines-pro-type-vararg lint for this file +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) +namespace ImGui +{ + +// threeway checkbox taken from https://github.com/ocornut/imgui/issues/2644 +bool CheckBoxTristate( const char* label, CheckBoxTriStateValue* v_tristate ) +{ + bool ret; + if( *v_tristate == CheckBoxTriStateValue::MIXED ) + { + ImGui::PushItemFlag( ImGuiItemFlags_MixedValue, true ); + bool b = false; + ret = ImGui::Checkbox( label, &b ); + if( ret ) + { + *v_tristate = CheckBoxTriStateValue::CHECKED; + } + ImGui::PopItemFlag(); + } + else + { + bool b = ( *v_tristate != CheckBoxTriStateValue::UNCHECKED ); + ret = ImGui::Checkbox( label, &b ); + if( ret ) + { + *v_tristate = (CheckBoxTriStateValue)(int)b; + } + } + return ret; +} + +CheckBoxTriStateValue GetCheckedStatus( int64_t checked, int64_t count ) +{ + if( checked == 0 ) + { + return CheckBoxTriStateValue::UNCHECKED; + } + else if( checked == count ) + { + return CheckBoxTriStateValue::CHECKED; + } + else + { + return CheckBoxTriStateValue::MIXED; + } +} + + +bool FontAwesomeButton( const FaIcon& icon, int id, float width, float height ) +{ + const float faButtonPadding = 4.0f; + const float glyphWidth = UiConsts::FONT_AWESOME_SIZE * icon.xyRatio; + const float paddingX = std::max( 0.0f, ( width - glyphWidth ) * 0.5f ); + ImGui::PushStyleVar( ImGuiStyleVar_FramePadding, ImVec2( paddingX, faButtonPadding ) ); + ImGui::PushID( id ); + bool ret = ImGui::Button( icon.text, ImVec2( width, height ) ); + ImGui::PopID(); + ImGui::PopStyleVar(); + + return ret; +} + +void FontAwesomeText( const FaIcon& icon, float width ) +{ + const float faButtonPadding = -4.0f; + const float glyphWidth = UiConsts::FONT_AWESOME_SIZE * icon.xyRatio; + const float paddingX = std::max( 0.0f, ( width - glyphWidth ) * 0.5f ); + ImGui::PushStyleVar( ImGuiStyleVar_FramePadding, ImVec2( paddingX, faButtonPadding ) ); + ImGui::TextUnformatted( icon.text ); + ImGui::PopStyleVar(); +} + +bool FontAwesomeSlashedButton( const FaIcon& icon, int id, float width, float height ) +{ + bool ret = ImGui::FontAwesomeButton( icon, id, width, height ); + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + float slashPadding = 1.5f; + ImGui::GetWindowDrawList()->AddLine( + ImVec2( min.x + slashPadding, max.y - slashPadding ), + ImVec2( max.x - slashPadding, min.y + slashPadding ), + ImGui::GetColorU32( ImGuiCol_Text ), + 2.0f ); + + return ret; +} + +const char* OpenCmfFileDialog() +{ + char const* filter[1] = { "*.cmf" }; + return tinyfd_openFileDialog( + "Open CMF File", + NULL, + 1, + filter, + "CMF Files", + 0 ); +} +} +// NOLINTEND(cppcoreguidelines-pro-type-vararg) \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiCustomWidgets.h b/src/viewer/rendering/ui/uiCustomWidgets.h new file mode 100644 index 0000000..79b8202 --- /dev/null +++ b/src/viewer/rendering/ui/uiCustomWidgets.h @@ -0,0 +1,62 @@ +// Copyright © 2026 CCP ehf. + +#pragma once + +#include +#include + +#include "uiConsts.h" + +namespace ImGui +{ +enum class CheckBoxTriStateValue +{ + UNCHECKED = 0, + CHECKED = 1, + MIXED = -1 +}; + +CheckBoxTriStateValue GetCheckedStatus( int64_t checked, int64_t count ); + +bool CheckBoxTristate( const char* label, CheckBoxTriStateValue* v_tristate ); +bool FontAwesomeButton( const FaIcon& icon, int id = 0, float width = UiConsts::BUTTON_WIDTH, float height = UiConsts::BUTTON_HEIGHT ); +bool FontAwesomeSlashedButton( const FaIcon& icon, int id = 0, float width = UiConsts::BUTTON_WIDTH, float height = UiConsts::BUTTON_HEIGHT ); +void FontAwesomeText( const FaIcon& icon, float width ); +const char* OpenCmfFileDialog(); + +template +void ComboBox( const char* name, const std::vector& items, int32_t selectedIndex, Callable onChange ) +{ + if( items.empty() ) + { + if( ImGui::BeginCombo( name, "N/A" ) ) + { + ImGui::EndCombo(); + } + return; + } + + if( selectedIndex < 0 || selectedIndex >= static_cast( items.size() ) ) + { + selectedIndex = 0; + } + + if( ImGui::BeginCombo( name, items[selectedIndex].c_str() ) ) + { + for( int32_t itemIndex = 0; itemIndex < static_cast( items.size() ); ++itemIndex ) + { + const bool isSelected = ( itemIndex == selectedIndex ); + if( ImGui::Selectable( items[itemIndex].c_str(), isSelected ) ) + { + onChange( itemIndex ); + } + + if( isSelected ) + { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } +} +} \ No newline at end of file diff --git a/src/viewer/rendering/uiDetailWindow.cpp b/src/viewer/rendering/ui/uiDetailWindow.cpp similarity index 100% rename from src/viewer/rendering/uiDetailWindow.cpp rename to src/viewer/rendering/ui/uiDetailWindow.cpp diff --git a/src/viewer/rendering/uiDetailWindow.h b/src/viewer/rendering/ui/uiDetailWindow.h similarity index 98% rename from src/viewer/rendering/uiDetailWindow.h rename to src/viewer/rendering/ui/uiDetailWindow.h index 9deec9e..8b35531 100644 --- a/src/viewer/rendering/uiDetailWindow.h +++ b/src/viewer/rendering/ui/uiDetailWindow.h @@ -2,7 +2,7 @@ #pragma once -#include "../appState.h" +#include "../../appState.h" class UIDetailWindow { diff --git a/src/viewer/rendering/ui/uiGeneralWindow.cpp b/src/viewer/rendering/ui/uiGeneralWindow.cpp new file mode 100644 index 0000000..49cd8b7 --- /dev/null +++ b/src/viewer/rendering/ui/uiGeneralWindow.cpp @@ -0,0 +1,718 @@ +// Copyright © 2026 CCP ehf. + +#include "uiGeneralWindow.h" + +#include +#include +#include +#include +#include +#include + +#include "uiCustomWidgets.h" + +// ImGui is using a lot of variadic functions for text formatting, so we disable the cppcoreguidelines-pro-type-vararg lint for this file +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) + +namespace +{ +template +void SetupAttribute( const char* name, const char* tooltip, bool disabled, Callable constructor ) +{ + ImGui::TableNextRow(); + + ImGui::BeginDisabled( disabled ); + ImGui::TableNextColumn(); + + ImGui::TextUnformatted( name ); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); + constructor(); + if( tooltip ) + { + ImGui::SetItemTooltip( "%s", tooltip ); + } + ImGui::EndDisabled(); +} + +using AxisTriCheckboxStates = std::vector>; +using AxisCheckboxStates = std::vector>; + +/*** +* @brief Combines the states of a mesh state for a given axis into a single vector of checkbox states. +* @param meshState The mesh state to combine the axis states from. +* @param getAxisStates A callable that takes a MeshState and returns a StateCollection of pairs of uint32_t and bool representing the axis states. +*/ +template +AxisCheckboxStates GetAxisCheckboxStates( const MeshState& meshState, GetAxisStates getAxisStates ) +{ + AxisCheckboxStates combinedStates; + for( const auto& axis : getAxisStates( meshState ) ) + { + auto [index, checked] = axis.GetValue(); + combinedStates.push_back( { index, checked } ); + } + return combinedStates; +} + +/*** +* @brief Combines the states of multiple mesh states for a given axis into a single vector of tri-state checkbox states. +* @param meshStates The collection of mesh states to combine the axis states from. +* @param getAxisStates A callable that takes a MeshState and returns a StateCollection of pairs of uint32_t and bool representing the axis states. +*/ +template +AxisTriCheckboxStates GetAxisTriCheckboxStates( StateCollection& meshStates, GetAxisStates getAxisStates ) +{ + AxisTriCheckboxStates combinedStates; + + for( auto& meshState : meshStates ) + { + auto axisStatesForMesh = GetAxisCheckboxStates( meshState.GetValue(), getAxisStates ); + // combine the states + for( const auto& state : axisStatesForMesh ) + { + auto index = state.first; + auto checked = state.second; + + auto foundState = std::find_if( combinedStates.begin(), combinedStates.end(), [&index]( const auto& existingState ) { + return existingState.first == index; + } ); + auto stateAsTriState = checked ? ImGui::CheckBoxTriStateValue::CHECKED : ImGui::CheckBoxTriStateValue::UNCHECKED; + if( foundState == combinedStates.end() ) + { + combinedStates.push_back( { index, stateAsTriState } ); + } + else if( foundState->second != stateAsTriState ) + { + foundState->second = ImGui::CheckBoxTriStateValue::MIXED; + } + } + } + + return combinedStates; +} + +/*** +* @brief Sets up a row of tri-state checkboxes for a model axis. +* @param checkboxStates The states of the checkboxes for the axis. +* @param name The name of the axis. +* @param changeCallback A callable that is invoked when a checkbox state changes. +*/ +template +void SetupModelAxisRow( AxisTriCheckboxStates& checkboxStates, const char* name, const Callable& changeCallback ) +{ + const int32_t maxIndex = checkboxStates.empty() ? -1 : static_cast( std::max_element( checkboxStates.begin(), checkboxStates.end(), []( const auto& a, const auto& b ) { + return a.first < b.first; + } )->first ); + + ImGui::BeginDisabled( maxIndex < 0 ); + ImGui::TableNextColumn(); + ImGui::Text( "%s", name ); + ImGui::SetItemTooltip( "Toggles the %s visualization for all meshes", name ); + ImGui::TableNextColumn(); + + if( maxIndex >= 0 ) + { + for( int32_t i = 0; i <= maxIndex; ++i ) + { + auto foundState = std::find_if( checkboxStates.begin(), checkboxStates.end(), [i]( const auto& state ) { + return state.first == static_cast( i ); + } ); + + if( foundState == checkboxStates.end() ) + { + ImGui::BeginDisabled( true ); + auto disabledValue = ImGui::CheckBoxTriStateValue::UNCHECKED; + ImGui::CheckBoxTristate( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &disabledValue ); + ImGui::SetItemTooltip( "No mesh has %s information for usage %d", name, i ); + ImGui::SameLine(); + ImGui::EndDisabled(); + } + else + { + auto value = foundState->second; + if( ImGui::CheckBoxTristate( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &value ) ) + { + changeCallback( value == ImGui::CheckBoxTriStateValue::CHECKED, static_cast( i ) ); + } + ImGui::SetItemTooltip( "Toggles debug visualization for %s with usage index %d for all meshes that have it", name, i ); + ImGui::SameLine(); + } + } + } + else + { + ImGui::BeginDisabled( true ); + bool value = false; + ImGui::Checkbox( ( std::string( "##" ) + name ).c_str(), &value ); + ImGui::SetItemTooltip( "Model doesn't have any %s", name ); + ImGui::SameLine(); + ImGui::EndDisabled(); + } + ImGui::EndDisabled(); +} + +/*** +* @brief Sets up a row of checkboxes for a mesh axis. +* @param checkboxStates The states of the checkboxes for the axis. +* @param name The name of the axis. +* @param changeCallback A callable that is invoked when a checkbox state changes. +*/ +template +void SetupMeshAxisRow( AxisCheckboxStates& checkboxStates, const char* name, const Callable& changeCallback ) +{ + const int32_t maxIndex = checkboxStates.empty() ? -1 : static_cast( std::max_element( checkboxStates.begin(), checkboxStates.end(), []( const auto& a, const auto& b ) { + return a.first < b.first; + } )->first ); + + ImGui::BeginDisabled( maxIndex < 0 ); + ImGui::TableNextColumn(); + ImGui::Text( "%s", name ); + ImGui::SetItemTooltip( "Toggles the %s visualization for this mesh", name ); + ImGui::TableNextColumn(); + + if( maxIndex >= 0 ) + { + for( int32_t i = 0; i <= maxIndex; ++i ) + { + auto foundState = std::find_if( checkboxStates.begin(), checkboxStates.end(), [i]( const auto& state ) { + return state.first == static_cast( i ); + } ); + + if( foundState == checkboxStates.end() ) + { + ImGui::BeginDisabled( true ); + auto disabledValue = false; + ImGui::Checkbox( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &disabledValue ); + ImGui::SetItemTooltip( "No mesh has %s information for usage %d", name, i ); + ImGui::SameLine(); + ImGui::EndDisabled(); + } + else + { + auto value = foundState->second; + if( ImGui::Checkbox( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &value ) ) + { + changeCallback( value, static_cast( i ) ); + } + ImGui::SetItemTooltip( "Toggles debug visualization for %s with usage index %d for this mesh", name, i ); + ImGui::SameLine(); + } + } + } + else + { + ImGui::BeginDisabled( true ); + bool value = false; + ImGui::Checkbox( ( std::string( "##" ) + name ).c_str(), &value ); + ImGui::SetItemTooltip( "Mesh doesn't have any %s", name ); + ImGui::SameLine(); + ImGui::EndDisabled(); + } + ImGui::EndDisabled(); +} + +template +void SetAxisStateForMeshes( StateCollection& meshStates, uint32_t index, bool checked, GetAxisStates getAxisStates ) +{ + for( auto& meshState : meshStates ) + { + auto& axisStates = getAxisStates( meshState.GetValue() ); + auto foundState = std::find_if( axisStates.begin(), axisStates.end(), [index]( const State>& state ) { + return state.GetValue().first == index; + } ); + if( foundState != axisStates.end() ) + { + foundState->SetValue( { index, checked } ); + } + } +} +} + +void UIGeneralWindow::Render( AppState& appState, float marginTop, float marginBottom ) +{ + const float width = (float)appState.windowSize.GetValue().first; + const float height = (float)appState.windowSize.GetValue().second; + + const float ySize = std::max( 1.0f, height - marginTop - marginBottom + 1 ); // +1 so we get an overlap of the borders + + ImGui::SetNextWindowPos( ImVec2( 0, marginTop ), ImGuiCond_Always ); + ImGui::SetNextWindowSizeConstraints( ImVec2( width / 16.0f, ySize ), ImVec2( width, ySize ) ); + ImGui::SetNextWindowSize( ImVec2( width / 4.0f, ySize ), ImGuiCond_FirstUseEver ); + ImGui::Begin( "CMF Info", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings ); + RenderGeneralInfo( appState ); + RenderMeshList( appState ); + RenderSkeletonList( appState ); + RenderAnimationOverrideList( appState ); + ImGui::End(); +} + +void UIGeneralWindow::RenderGeneralInfo( AppState& appState ) +{ + auto data = appState.cmfContent.GetValue(); + + // fetch the data + uint32_t vertexCount = 0; + uint32_t indexCount = 0; + size_t maxLod = 0; + std::vector lodNames{ "Auto" }; + if( data ) + { + const auto& cmfMeshes = data->m_cmfData->meshes; + const auto& modelStateMeshes = appState.modelState.meshes; + assert( cmfMeshes.size() == modelStateMeshes.size() ); + + for( uint32_t meshIndex = 0; meshIndex < cmfMeshes.size(); ++meshIndex ) + { + const auto& mesh = cmfMeshes[meshIndex]; + + auto& meshState = modelStateMeshes[meshIndex].GetValue(); + const auto& lod = mesh.lods[meshState.activeLod.GetValue()]; + + vertexCount += cmf::GetStreamElementCount( lod.vb ); + indexCount += cmf::GetStreamElementCount( lod.ib ); + maxLod = std::max( maxLod, mesh.lods.size() ); + } + + for( size_t i = 0; i < maxLod; ++i ) + { + lodNames.push_back( "Lod " + std::to_string( i ) ); + } + } + + ImGui::SeparatorText( "General" ); + if( ImGui::BeginTable( "##table", 2 ) ) + { + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); + + auto fileName = std::filesystem::path( appState.cmfPath.GetValue() ).filename().string(); + auto tooltip = std::string( "Full path: " ) + appState.cmfPath.GetValue(); + SetupAttribute( "Path", tooltip.c_str(), false, [&]() { ImGui::InputText( "##label", fileName.data(), fileName.size(), ImGuiInputTextFlags_ReadOnly ); } ); + + SetupAttribute( "Lods", "Number of LODs in the model", data == nullptr, [&]() { + ImGui::ComboBox( "##lod", lodNames, appState.modelState.selectedLod.GetValue() + 1, [&appState]( int32_t selectedLod ) { + appState.modelState.selectedLod.SetValue( selectedLod - 1 ); + } ); + } ); + SetupAttribute( "Vertices", "Vertex count of all meshes", false, [&]() { ImGui::Text( "%u", vertexCount ); } ); + SetupAttribute( "Indices", "Index count of all meshes", false, [&]() { ImGui::Text( "%u", indexCount ); } ); + SetupAttribute( "Meshes", "Number of meshes", false, [&]() { ImGui::Text( "%zu", data ? data->m_cmfData->meshes.size() : 0 ); } ); + + SetupAttribute( "Visualization", "Visualization mode", data == nullptr, [&]() { + const auto& availableShaders = appState.modelState.availableShaders.GetValue(); + + // get the names + std::vector shaderNames; + shaderNames.reserve( availableShaders.size() ); + std::transform( availableShaders.begin(), availableShaders.end(), std::back_inserter( shaderNames ), []( const auto& pair ) { return pair.first; } ); + + // find the index of the active shader in the available shaders list + const auto& activeShader = appState.modelState.activeShader.GetValue(); + const auto& foundActiveShader = std::find_if( availableShaders.begin(), availableShaders.end(), [&activeShader]( const auto& pair ) { + return pair.first == activeShader.first && pair.second == activeShader.second; + } ); + + int32_t activeShaderIndex = static_cast( std::distance( availableShaders.begin(), foundActiveShader ) ); + + ImGui::ComboBox( "##visualization", shaderNames, activeShaderIndex, [&appState]( int32_t selectedIndex ) { + const auto& availableShaders = appState.modelState.availableShaders.GetValue(); + if( selectedIndex >= 0 && selectedIndex < static_cast( availableShaders.size() ) ) + { + appState.modelState.activeShader.SetValue( availableShaders[selectedIndex] ); + } + } ); + } ); + + SetupAttribute( "Display", "Toggle display of all meshes", data == nullptr, [&]() { + auto checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { + return state.GetValue().display.GetValue(); + } ); + ImGui::CheckBoxTriStateValue checkedState = ImGui::GetCheckedStatus( checkedCount, static_cast( appState.modelState.meshes.size() ) ); + if( ImGui::CheckBoxTristate( "##displaycheckbox", &checkedState ) ) + { + std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checkedState]( State& state ) { + state.GetValue().display.SetValue( checkedState == ImGui::CheckBoxTriStateValue::CHECKED ); + } ); + }; + } ); + + SetupAttribute( "Bounding Box", "Whole model bounding box", data == nullptr, [&] { + bool checked = appState.modelState.modelBoundingBox.GetValue(); + if( ImGui::Checkbox( "##boundingboxcheckbox", &checked ) ) + { + appState.modelState.modelBoundingBox.SetValue( checked ); + } + } ); + + SetupAttribute( "Wireframe Overlay", "Wireframe overlay for all meshes", data == nullptr, [&] { + auto checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { + return state.GetValue().wireframeOverlay.GetValue(); + } ); + ImGui::CheckBoxTriStateValue checkedState = ImGui::GetCheckedStatus( checkedCount, static_cast( appState.modelState.meshes.size() ) ); + if( ImGui::CheckBoxTristate( "##wireframecheckbox", &checkedState ) ) + { + std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checkedState]( State& state ) { + state.GetValue().wireframeOverlay.SetValue( checkedState == ImGui::CheckBoxTriStateValue::CHECKED ); + } ); + } + } ); + + bool hasAudioOcclusionMeshes = false; + if( data ) + { + hasAudioOcclusionMeshes = std::any_of( data->m_cmfData->meshes.begin(), data->m_cmfData->meshes.end(), []( const cmf::Mesh& mesh ) { + return !mesh.audioOcclusionMesh.vertices.empty(); + } ); + } + + SetupAttribute( "Audio Occlusion Mesh", "Audio occlusion mesh for all meshes", !hasAudioOcclusionMeshes, [&]() { + size_t checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { + return state.GetValue().audioOcclusionMesh.GetValue(); + } ); + ImGui::CheckBoxTriStateValue checkedState = ImGui::GetCheckedStatus( checkedCount, static_cast( appState.modelState.meshes.size() ) ); + if( ImGui::CheckBoxTristate( "##audioocclusioncheckbox", &checkedState ) ) + { + std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checkedState]( State& state ) { + state.GetValue().audioOcclusionMesh.SetValue( checkedState == ImGui::CheckBoxTriStateValue::CHECKED ); + } ); + } + } ); + + auto normalCheckboxes = GetAxisTriCheckboxStates( appState.modelState.meshes, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexNormals; + } ); + auto tangentCheckboxes = GetAxisTriCheckboxStates( appState.modelState.meshes, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexTangents; + } ); + auto binormalCheckboxes = GetAxisTriCheckboxStates( appState.modelState.meshes, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexBinormals; + } ); + + SetupModelAxisRow( normalCheckboxes, "Normals", [&appState]( bool checked, uint32_t index ) { + SetAxisStateForMeshes( appState.modelState.meshes, index, checked, []( MeshState& meshState ) -> auto& { + return meshState.showVertexNormals; + } ); + } ); + + SetupModelAxisRow( tangentCheckboxes, "Tangents", [&appState]( bool checked, uint32_t index ) { + SetAxisStateForMeshes( appState.modelState.meshes, index, checked, []( MeshState& meshState ) -> auto& { + return meshState.showVertexTangents; + } ); + } ); + + SetupModelAxisRow( binormalCheckboxes, "Binormals", [&appState]( bool checked, uint32_t index ) { + SetAxisStateForMeshes( appState.modelState.meshes, index, checked, []( MeshState& meshState ) -> auto& { + return meshState.showVertexBinormals; + } ); + } ); + + ImGui::EndTable(); + } +} + +void UIGeneralWindow::RenderMeshList( AppState& appState ) +{ + std::string header = "Meshes (" + std::to_string( appState.modelState.meshes.size() ) + ")"; + + ImGui::SeparatorText( header.c_str() ); + const auto data = appState.cmfContent.GetValue(); + size_t meshCount = data != nullptr && data->m_cmfData != nullptr ? data->m_cmfData->meshes.size() : 0; + + if( meshCount != appState.modelState.meshes.size() ) + { + return; + } + + for( int32_t index = 0; index < appState.modelState.meshes.size(); ++index ) + { + const auto& mesh = data->m_cmfData->meshes[index]; + auto& meshState = appState.modelState.meshes[index].GetValue(); + + RenderMeshInfo( mesh, meshState ); + } +} + +void UIGeneralWindow::RenderMeshInfo( const cmf::Mesh& mesh, MeshState& meshState ) +{ + if( ImGui::TreeNode( cmf::ToStdString( mesh.name ).c_str() ) ) + { + if( ImGui::BeginTable( "##table", 2 ) ) + { + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); + ImGui::TableNextRow(); + SetupAttribute( "Display", "Toggle display of this mesh", false, [&]() { + bool checked = meshState.display.GetValue(); + if( ImGui::Checkbox( "##displaycheckbox", &checked ) ) + { + meshState.display.SetValue( checked ); + } + } ); + SetupAttribute( "Bounding Box", "Toggles the bounding box of this mesh", false, [&]() { + bool checked = meshState.renderBoundingBox.GetValue(); + if( ImGui::Checkbox( "##boundingboxcheckbox", &checked ) ) + { + meshState.renderBoundingBox.SetValue( checked ); + } + } ); + SetupAttribute( "Wireframe Overlay", "Wireframe overlay for this mesh", false, [&]() { + bool checked = meshState.wireframeOverlay.GetValue(); + if( ImGui::Checkbox( "##wireframecheckbox", &checked ) ) + { + meshState.wireframeOverlay.SetValue( checked ); + } + } ); + + SetupAttribute( "Audio Occlusion Mesh", "Audio occlusion mesh for this mesh", mesh.audioOcclusionMesh.vertices.empty(), [&]() { + bool checked = meshState.audioOcclusionMesh.GetValue(); + if( ImGui::Checkbox( "##audioocclusioncheckbox", &checked ) ) + { + meshState.audioOcclusionMesh.SetValue( checked ); + } + } ); + + auto normalCheckboxes = GetAxisCheckboxStates( meshState, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexNormals; + } ); + auto tangentCheckboxes = GetAxisCheckboxStates( meshState, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexTangents; + } ); + auto binormalCheckboxes = GetAxisCheckboxStates( meshState, []( const MeshState& meshState ) -> auto& { + return meshState.showVertexBinormals; + } ); + + SetupMeshAxisRow( normalCheckboxes, "Normals", [&meshState]( bool checked, uint32_t index ) { + const auto& foundState = std::find_if( meshState.showVertexNormals.begin(), meshState.showVertexNormals.end(), [index]( const State>& state ) { + return state.GetValue().first == index; + } ); + + if( foundState != meshState.showVertexNormals.end() ) + { + foundState->SetValue( { index, checked } ); + } + } ); + + SetupMeshAxisRow( tangentCheckboxes, "Tangents", [&meshState]( bool checked, uint32_t index ) { + const auto& foundState = std::find_if( meshState.showVertexTangents.begin(), meshState.showVertexTangents.end(), [index]( const State>& state ) { + return state.GetValue().first == index; + } ); + + if( foundState != meshState.showVertexTangents.end() ) + { + foundState->SetValue( { index, checked } ); + } + } ); + + SetupMeshAxisRow( binormalCheckboxes, "Binormals", [&meshState]( bool checked, uint32_t index ) { + const auto& foundState = std::find_if( meshState.showVertexBinormals.begin(), meshState.showVertexBinormals.end(), [index]( const State>& state ) { + return state.GetValue().first == index; + } ); + + if( foundState != meshState.showVertexBinormals.end() ) + { + foundState->SetValue( { index, checked } ); + } + } ); + + ImGui::EndTable(); + RenderMorphList( mesh, meshState ); + } + ImGui::TreePop(); + } +} + +void UIGeneralWindow::RenderMorphList( const cmf::Mesh& mesh, MeshState& meshState ) +{ + if( ImGui::TreeNode( "##morphs", "Morphs (%zu)", mesh.morphTargets.targets.size() ) ) + { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted( ICON_FA_MAGNIFYING_GLASS.text ); + ImGui::SameLine(); + m_morphFilter.Draw( "##morphfilter", ImGui::GetContentRegionAvail().x ); + ImGui::SetItemTooltip( "Filter morph targets" ); + + const auto& currentLod = mesh.lods[meshState.activeLod.GetValue()]; + for( size_t morphIndex = 0; morphIndex < currentLod.morphTargets.size(); ++morphIndex ) + { + const auto morphName = cmf::ToStdString( mesh.morphTargets.targets[morphIndex].name ); + if( !m_morphFilter.PassFilter( morphName.c_str() ) ) + { + continue; + } + + ImGui::SeparatorText( morphName.c_str() ); + + if( ImGui::BeginTable( "##table", 3 ) ) + { + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed, UiConsts::BUTTON_WIDTH ); + ImGui::TableNextRow(); + + ImGui::TableNextColumn(); + ImGui::Text( "Weight" ); + const auto& morph = currentLod.morphTargets[morphIndex]; + auto& morphState = meshState.morphs[morphIndex]; + + auto [weight, enabled] = morphState.GetValue(); + + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); + + if( ImGui::SliderFloat( ( std::string( "##morphslider" ) + std::to_string( morphIndex ) ).c_str(), &weight, 0.0f, 1.0f ) ) + { + morphState.SetValue( { weight, enabled } ); + } + ImGui::TableNextColumn(); + + if( ImGui::Checkbox( ( std::string( "##morphcheckbox" ) + std::to_string( morphIndex ) ).c_str(), &enabled ) ) + { + morphState.SetValue( { weight, enabled } ); + } + ImGui::EndTable(); + } + } + ImGui::TreePop(); + } +} + +void UIGeneralWindow::RenderSkeletonList( AppState& appState ) +{ + const auto& data = appState.cmfContent.GetValue(); + bool hasSkeletons = data && !data->m_cmfData->skeletons.empty(); + size_t skeletonCount = hasSkeletons ? data->m_cmfData->skeletons.size() : 0; + std::string header = "Skeletons (" + std::to_string( skeletonCount ) + ")"; + ImGui::SeparatorText( header.c_str() ); + + if( ImGui::BeginTable( "##table", 2 ) ) + { + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); + ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); + + SetupAttribute( "Display Joints", "Shows skeleton joints", !hasSkeletons, [&]() { + bool checked = appState.modelState.jointDebug.GetValue(); + if( ImGui::Checkbox( "##displayjoints", &checked ) ) + { + appState.modelState.jointDebug.SetValue( checked ); + } + } ); + + SetupAttribute( "Display Joint Axes", "Shows skeleton joint axes", !hasSkeletons, [&]() { + bool checked = appState.modelState.jointAxisDebug.GetValue(); + if( ImGui::Checkbox( "##displayjointaxes", &checked ) ) + { + appState.modelState.jointAxisDebug.SetValue( checked ); + } + } ); + + SetupAttribute( "Display Bones", "Shows skeleton bones", !hasSkeletons, [&]() { + bool checked = appState.modelState.boneDebug.GetValue(); + if( ImGui::Checkbox( "##displaybones", &checked ) ) + { + appState.modelState.boneDebug.SetValue( checked ); + } + } ); + ImGui::EndTable(); + } +} + +void UIGeneralWindow::RenderAnimationOverrideList( AppState& appState ) +{ + ImGui::SeparatorText( "Animation Owners" ); + + const auto& data = appState.cmfContent.GetValue(); + bool disabled = data == nullptr || data->m_cmfData->skeletons.empty(); + ImGui::BeginDisabled( disabled ); + + // button to add an animation owner from a cmf file + if( ImGui::Button( "+", ImVec2( ImGui::GetContentRegionAvail().x, UiConsts::BUTTON_HEIGHT ) ) ) + { + auto* path = ImGui::OpenCmfFileDialog(); + if( path != nullptr ) + { + auto data = CmfContentLoader::LoadContentFromFile( path ); + if( data ) + { + appState.modelState.animationOverrides.AddState( data ); + appState.modelState.activeAnimationOwner.SetValue( data ); + } + } + } + if( data == nullptr ) + { + ImGui::SetItemTooltip( "Adds an animation owner from a cmf file. Disabled because no data is loaded." ); + } + else if( data->m_cmfData->skeletons.empty() ) + { + ImGui::SetItemTooltip( "Adds an animation owner from a cmf file. Disabled since model has no skeletons." ); + } + else + { + ImGui::SetItemTooltip( "Adds an animation owner from a cmf file." ); + } + + std::vector> allModels{}; + if( data ) + { + allModels.reserve( appState.modelState.animationOverrides.size() + 1 ); + allModels.push_back( data ); + } + std::transform( appState.modelState.animationOverrides.begin(), appState.modelState.animationOverrides.end(), std::back_inserter( allModels ), []( const auto& state ) { return state.GetValue(); } ); + + const auto& skeletonOwners = appState.modelState.animationOverrides; + + uint32_t index = 0; + for( auto& animationOwner : allModels ) + { + auto name = std::filesystem::path( animationOwner->m_filePath ).filename().string(); + if( index == 0 ) + { + name += " (model)"; + } + + if( ImGui::RadioButton( name.c_str(), appState.modelState.activeAnimationOwner.GetValue() == animationOwner ) ) + { + appState.modelState.activeAnimationOwner.SetValue( animationOwner ); + } + + if( ImGui::BeginItemTooltip() ) + { + if( index == 0 ) + { + ImGui::Text( "Model skeleton and animations" ); + } + else + { + ImGui::Text( "Animation owner from %s", animationOwner->m_filePath.c_str() ); + } + ImGui::Text( "Skeletons" ); + for( const auto& skeleton : animationOwner->m_cmfData->skeletons ) + { + ImGui::BulletText( "%s has %zu bones", cmf::ToStdString( skeleton.name ).c_str(), skeleton.bones.size() ); + } + ImGui::EndTooltip(); + } + + if( index != 0 ) + { + ImGui::SameLine( ImGui::GetContentRegionAvail().x - UiConsts::BUTTON_WIDTH ); + std::string label = "-##" + std::to_string( index ); + if( ImGui::Button( label.c_str() ) ) + { + appState.modelState.animationOverrides.RemoveAt( index - 1 ); + + if( appState.modelState.activeAnimationOwner.GetValue() == animationOwner ) + { + appState.modelState.activeAnimationOwner.SetValue( appState.cmfContent.GetValue() ); + } + } + ImGui::SetItemTooltip( "Removes %s ", name.c_str() ); + } + ++index; + } + ImGui::EndDisabled(); +} + + +// NOLINTEND(cppcoreguidelines-pro-type-vararg) \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiGeneralWindow.h b/src/viewer/rendering/ui/uiGeneralWindow.h new file mode 100644 index 0000000..20af2a1 --- /dev/null +++ b/src/viewer/rendering/ui/uiGeneralWindow.h @@ -0,0 +1,22 @@ +// Copyright © 2026 CCP ehf. + +#pragma once + +#include "../../appState.h" +#include + +class UIGeneralWindow +{ +public: + void Render( AppState& appState, float marginTop, float marginBottom ); + +private: + void RenderGeneralInfo( AppState& appState ); + void RenderMeshList( AppState& appState ); + void RenderMeshInfo( const cmf::Mesh& mesh, MeshState& meshState ); + void RenderMorphList( const cmf::Mesh& mesh, MeshState& meshState ); + void RenderSkeletonList( AppState& appState ); + void RenderAnimationOverrideList( AppState& appState ); + + ImGuiTextFilter m_morphFilter; +}; \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiMenubar.cpp b/src/viewer/rendering/ui/uiMenubar.cpp new file mode 100644 index 0000000..415aacf --- /dev/null +++ b/src/viewer/rendering/ui/uiMenubar.cpp @@ -0,0 +1,126 @@ +// Copyright © 2026 CCP ehf. + +#include "uiMenubar.h" +#include + +#include "uiCustomWidgets.h" + +void UiMenubar::Render( AppState& appState ) +{ + if( ImGui::BeginMainMenuBar() ) + { + if( ImGui::BeginMenu( "File" ) ) + { + if( ImGui::MenuItem( "Open", "Ctrl+O" ) ) + { + auto filePath = ImGui::OpenCmfFileDialog(); + if( filePath != nullptr ) + { + appState.cmfLoadRequest.SetValue( filePath ); + } + } + if( appState.cmfContent.GetValue() == nullptr ) + { + ImGui::BeginDisabled(); + } + if( ImGui::MenuItem( "Load Animation Override" ) ) + { + const auto* filePath = ImGui::OpenCmfFileDialog(); + if( filePath != nullptr ) + { + auto data = CmfContentLoader::LoadContentFromFile( filePath ); + if( data ) + { + appState.modelState.animationOverrides.AddState( data ); + } + } + } + if( appState.cmfContent.GetValue() == nullptr ) + { + ImGui::EndDisabled(); + } + + ImGui::Separator(); + if( ImGui::MenuItem( "Exit" ) ) + { + appState.exitRequested.SetValue( true ); + } + ImGui::EndMenu(); + } + auto content = appState.cmfContent.GetValue(); + + if( ImGui::BeginMenu( "View" ) ) + { + if( ImGui::BeginMenu( "Camera", content != nullptr ) ) + { + std::vector> focusObjects; + if( content != nullptr ) + { + const auto& meshes = content->m_cmfData->meshes; + + if( meshes.size() > 1 ) + { + focusObjects.push_back( { "Whole Model", content->GetBoundingSphere() } ); + } + std::for_each( meshes.begin(), meshes.end(), [&focusObjects]( const cmf::Mesh& mesh ) { + focusObjects.push_back( { cmf::ToStdString( mesh.name ), CcpMath::Sphere( mesh.bounds ) } ); + } ); + } + + uint32_t index = 0; + for( const auto& [menuName, sphere] : focusObjects ) + { + std::string menuLabel = "Focus on " + menuName; + const char* shortcut = nullptr; + if( index == 0 ) + { + shortcut = "Ctrl+F"; + } + if( ImGui::MenuItem( menuLabel.c_str(), shortcut ) ) + { + appState.cameraFocus.ForceSetValue( sphere ); + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_FOCUS ); + } + ++index; + } + ImGui::Separator(); + if( ImGui::MenuItem( "Look Right (+X)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_RIGHT ); + } + if( ImGui::MenuItem( "Look Left (-X)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_LEFT ); + } + if( ImGui::MenuItem( "Look Up (+Y)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_UP ); + } + if( ImGui::MenuItem( "Look Down (-Y)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_DOWN ); + } + if( ImGui::MenuItem( "Look Front (-Z)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_FRONT ); + } + if( ImGui::MenuItem( "Look Back (+Z)" ) ) + { + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_BACK ); + } + ImGui::EndMenu(); + } + ImGui::Separator(); + + bool uiShown = appState.showUi.GetValue(); + const char* toggleUiLabel = uiShown ? "Hide UI" : "Show UI"; + if( ImGui::MenuItem( toggleUiLabel, "Ctrl+F12" ) ) + { + appState.showUi.SetValue( !uiShown ); + } + ImGui::EndMenu(); + } + + ImGui::EndMainMenuBar(); + } +} diff --git a/src/viewer/rendering/ui/uiMenubar.h b/src/viewer/rendering/ui/uiMenubar.h new file mode 100644 index 0000000..458c782 --- /dev/null +++ b/src/viewer/rendering/ui/uiMenubar.h @@ -0,0 +1,11 @@ +// Copyright © 2026 CCP ehf. + +#pragma once + +#include "../../appState.h" + +class UiMenubar +{ +public: + void Render( AppState& appState ); +}; \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiRenderer.cpp b/src/viewer/rendering/ui/uiRenderer.cpp new file mode 100644 index 0000000..ce9c47a --- /dev/null +++ b/src/viewer/rendering/ui/uiRenderer.cpp @@ -0,0 +1,250 @@ +// Copyright © 2026 CCP ehf. + +#include "uiRenderer.h" + +#include +#include +#include +#include +#include +#include "appState.h" +#include "uiConsts.h" +#include "uiCustomWidgets.h" +#include "../vulkan/vulkanerrors.h" + + +// ImGui is using a lot of variadic functions for text formatting, so we disable the cppcoreguidelines-pro-type-vararg lint for this file +// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) + + +UIRenderer::UIRenderer( std::shared_ptr renderer ) : + m_renderer( renderer ), + m_graphicsCommandBuffer( renderer.get() ) +{ +} + +UIRenderer::~UIRenderer() +{ + ImGui_ImplVulkan_Shutdown(); + ImGui::DestroyContext(); +} + +static void check_vk_result( VkResult err ) +{ + if( err == VK_SUCCESS ) + { + return; + } + Log::Error( "[vulkan] Error: VkResult = %d\n", err ); + if( err < 0 ) + { + abort(); + } +} + +void UIRenderer::Initialize( GLFWwindow* window, AppState& state ) +{ + Log::Info( "Initializing UI Renderer" ); + // Setup Dear ImGui context + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls + io.Fonts->AddFontDefault(); + + static const ImWchar iconRanges[] = { ICON_MIN_FA, ICON_MAX_FA, 0 }; + + ImFontConfig config; + config.MergeMode = true; + config.FontDataOwnedByAtlas = false; + // add fontawesome icons + io.Fonts->AddFontFromMemoryTTF( (void*)fa_solid_900_ttf_data, static_cast( fa_solid_900_ttf_size ), UiConsts::FONT_AWESOME_SIZE, &config, iconRanges ); + // Setup Dear ImGui style + ImGui::StyleColorsDark(); + + Device* device = m_renderer->GetDevice(); + const Swapchain* swapchain = m_renderer->GetSwapchain(); + VkDevice logicalDevice = device->GetLogicalDevice(); + const VkAllocationCallbacks* allocator = m_renderer->GetAllocator(); + + assert( device->GetQueueFamilyIndices().graphicsFamily.has_value() ); + + VkDescriptorPoolSize pool_sizes[] = { + { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, IMGUI_IMPL_VULKAN_MINIMUM_IMAGE_SAMPLER_POOL_SIZE }, + }; + VkDescriptorPoolCreateInfo pool_info = {}; + pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + pool_info.maxSets = 0; + for( VkDescriptorPoolSize& pool_size : pool_sizes ) + pool_info.maxSets += pool_size.descriptorCount; + pool_info.poolSizeCount = (uint32_t)1; + pool_info.pPoolSizes = pool_sizes; + + CR( vkCreateDescriptorPool( device->GetLogicalDevice(), &pool_info, allocator, &m_descriptorPool ) ); + + // Setup Platform/Renderer backends + ImGui_ImplGlfw_InitForVulkan( window, true ); + ImGui_ImplVulkan_InitInfo init_info = {}; + init_info.Instance = m_renderer->GetVulkanInstance(); + init_info.PhysicalDevice = device->GetPhysicalDevice(); + init_info.Device = logicalDevice; + init_info.QueueFamily = device->GetQueueFamilyIndices().graphicsFamily.value(); + init_info.Queue = device->GetGraphicsQueue(); + init_info.DescriptorPool = m_descriptorPool; + init_info.MinImageCount = swapchain->GetMinImageCount(); + init_info.ImageCount = swapchain->GetImageCount(); + init_info.Allocator = allocator; + init_info.RenderPass = VK_NULL_HANDLE; + init_info.Subpass = 0; + init_info.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + init_info.CheckVkResultFn = check_vk_result; + init_info.UseDynamicRendering = true; + + //dynamic rendering parameters for imgui to use + auto swapchainFormat = swapchain->GetFormat(); + init_info.PipelineRenderingCreateInfo = {}; + init_info.PipelineRenderingCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO; + init_info.PipelineRenderingCreateInfo.colorAttachmentCount = 1; + init_info.PipelineRenderingCreateInfo.pColorAttachmentFormats = &swapchainFormat; + init_info.PipelineRenderingCreateInfo.depthAttachmentFormat = m_renderer->GetDepthTexture()->GetFormat(); + init_info.PipelineRenderingCreateInfo.stencilAttachmentFormat = m_renderer->GetDepthTexture()->GetFormat(); + + ImGui_ImplVulkan_Init( &init_info ); + + state.windowSize.RegisterCallback( [this]( std::pair size, AppState& appState ) { + auto [width, height] = size; + m_graphicsCommandBuffer.SetRenderSize( width, height ); + } ); + + state.cmfContent.RegisterCallback( [this]( std::shared_ptr content, AppState& appState ) { + if( content == nullptr ) + { + m_loadStatus = LoadStatus::FAILED; + } + else + { + m_loadStatus = LoadStatus::SUCCESSFUL; + } + } ); + + auto [width, height] = state.windowSize.GetValue(); + m_graphicsCommandBuffer.SetRenderSize( width, height ); +} + + +void UIRenderer::BeginFrame() +{ + // Start the Dear ImGui frame + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); +} + +void UIRenderer::Render( AppState& appState ) +{ + SetupUi( appState ); + m_graphicsCommandBuffer.Begin( m_renderer.get() ); + ImGui::Render(); + + ImGui_ImplVulkan_RenderDrawData( ImGui::GetDrawData(), m_renderer->GetCurrentGraphicVkCommandBuffer() ); + m_graphicsCommandBuffer.End(); +} + +void UIRenderer::SetupUi( AppState& appState ) +{ + m_menubar.Render( appState ); + if( appState.showUi.GetValue() ) + { + m_generalWindow.Render( appState, UiConsts::MENU_BAR_HEIGHT, UiConsts::ANIMATION_PLAYER_HEIGHT ); + m_detailWindow.Render( appState, UiConsts::MENU_BAR_HEIGHT, UiConsts::ANIMATION_PLAYER_HEIGHT ); + m_animationPlayback.Render( appState ); + } + SetupPopupWindows( appState ); +} + + +void UIRenderer::SetupPopupWindows( AppState& appState ) +{ + if( m_loadStatus == LoadStatus::FAILED ) + { + ImGui::OpenPopup( "Error" ); + bool open = true; + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos( center, ImGuiCond_Appearing, ImVec2( 0.5f, 0.5f ) ); + + if( ImGui::BeginPopupModal( "Error", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse ) ) + { + auto filePath = appState.cmfPath.GetValue(); + ImGui::Text( "Failed to load" ); + ImGui::Text( "%s", filePath.c_str() ); + // At some point I can add the failure reason here... + ImGui::Separator(); + ImGui::NewLine(); + ImVec2 availableSize = ImGui::GetContentRegionAvail(); + ImGui::SameLine( availableSize.x / 2.0f - 50.0f ); + + if( ImGui::Button( "Ok", ImVec2( 100.0, 24 ) ) ) + { + m_loadStatus = LoadStatus::NOTHING_LOADED; + } + ImGui::EndPopup(); + } + } + else + { + m_loadStatus = LoadStatus::NOTHING_LOADED; + } +} + +void UIRenderer::Update( AppState& appstate ) +{ + UpdateInputs( appstate ); +} + +void UIRenderer::UpdateInputs( AppState& appState ) +{ + auto io = ImGui::GetIO(); + if( !io.WantCaptureMouse ) + { + MouseState mouseState{}; + mouseState.position = { io.MousePos.x, io.MousePos.y }; + mouseState.previousPosition = { io.MousePosPrev.x, io.MousePosPrev.y }; + mouseState.wheelDelta = io.MouseWheel; + mouseState.leftButton = io.MouseDown[0]; + mouseState.middleButton = io.MouseDown[2]; + mouseState.rightButton = io.MouseDown[1]; + appState.mouseState.SetValue( mouseState ); + } + else + { + appState.mouseState.Reset(); + } + // Handle ui keyboard shortcuts + if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_O ) ) + { + // file open + auto filePath = ImGui::OpenCmfFileDialog(); + if( filePath != nullptr ) + { + appState.cmfLoadRequest.SetValue( filePath ); + } + } + const auto content = appState.cmfContent.GetValue(); + if( content != nullptr ) + { + if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_F ) ) + { + // focus camera on model + appState.cameraFocus.ForceSetValue( content->GetBoundingSphere() ); + appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_FOCUS ); + } + } + if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_F12 ) ) + { + appState.showUi.SetValue( !appState.showUi.GetValue() ); + } +} + + +// NOLINTEND(cppcoreguidelines-pro-type-vararg) \ No newline at end of file diff --git a/src/viewer/rendering/ui/uiRenderer.h b/src/viewer/rendering/ui/uiRenderer.h new file mode 100644 index 0000000..6aee38b --- /dev/null +++ b/src/viewer/rendering/ui/uiRenderer.h @@ -0,0 +1,50 @@ +// Copyright © 2026 CCP ehf. + +#include "appState.h" +#include "rendering/renderer.h" +#include "uiDetailWindow.h" +#include "uiMenubar.h" +#include "uiGeneralWindow.h" +#include "uiAnimationPlayback.h" +#include "../vulkan/commandbuffer.h" + +// Handles rendering the UI +class UIRenderer +{ +public: + UIRenderer( std::shared_ptr ); + ~UIRenderer(); + + void Initialize( GLFWwindow* window, AppState& appState ); + void BeginFrame(); + + void Render( AppState& appState ); + + void SetupUi( AppState& appState ); + void CMFInfoWindow( AppState& appState ); + void Update( AppState& state ); + +private: + void SetupPopupWindows( AppState& appState ); + void UpdateInputs( AppState& state ); + + enum LoadStatus + { + FAILED, + SUCCESSFUL, + NOTHING_LOADED + }; + + void UpdateUiState( AppState& appState ); + + VkDescriptorPool m_descriptorPool{ VK_NULL_HANDLE }; + + std::shared_ptr m_renderer; + GraphicsCommandBuffer m_graphicsCommandBuffer; + + LoadStatus m_loadStatus{ NOTHING_LOADED }; + UIGeneralWindow m_generalWindow{}; + UIDetailWindow m_detailWindow{}; + UIAnimationPlayback m_animationPlayback{}; + UiMenubar m_menubar{}; +}; diff --git a/src/viewer/rendering/uiRenderer.cpp b/src/viewer/rendering/uiRenderer.cpp deleted file mode 100644 index a7e35d3..0000000 --- a/src/viewer/rendering/uiRenderer.cpp +++ /dev/null @@ -1,1308 +0,0 @@ -// Copyright © 2026 CCP ehf. - -#include "uiRenderer.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "appState.h" -#include "vulkan/vulkanerrors.h" - -#include - -// ImGui is using a lot of variadic functions for text formatting, so we disable the cppcoreguidelines-pro-type-vararg lint for this file -// NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) -// -// taken from https://github.com/ocornut/imgui/issues/2644 -namespace ImGui -{ - -// threeway checkbox -bool CheckBoxTristate( const char* label, CheckBoxTriStateValue* v_tristate ) -{ - bool ret; - if( *v_tristate == CheckBoxTriStateValue::MIXED ) - { - ImGui::PushItemFlag( ImGuiItemFlags_MixedValue, true ); - bool b = false; - ret = ImGui::Checkbox( label, &b ); - if( ret ) - { - *v_tristate = CheckBoxTriStateValue::CHECKED; - } - ImGui::PopItemFlag(); - } - else - { - bool b = ( *v_tristate != CheckBoxTriStateValue::UNCHECKED ); - ret = ImGui::Checkbox( label, &b ); - if( ret ) - { - *v_tristate = (CheckBoxTriStateValue)(int)b; - } - } - return ret; -} -}; - -const float MENU_BAR_HEIGHT = 18.0f; -const float ANIMATION_PLAYER_HEIGHT = 36.0f; -const float BUTTON_SIZE = 18.0f; - -UIRenderer::UIRenderer( std::shared_ptr renderer ) : - m_renderer( renderer ), - m_graphicsCommandBuffer( renderer.get() ) -{ -} - -UIRenderer::~UIRenderer() -{ - ImGui_ImplVulkan_Shutdown(); - ImGui::DestroyContext(); -} - -static void check_vk_result( VkResult err ) -{ - if( err == VK_SUCCESS ) - { - return; - } - Log::Error( "[vulkan] Error: VkResult = %d\n", err ); - if( err < 0 ) - { - abort(); - } -} - -void UIRenderer::Initialize( GLFWwindow* window, AppState& state ) -{ - Log::Info( "Initializing UI Renderer" ); - // Setup Dear ImGui context - IMGUI_CHECKVERSION(); - ImGui::CreateContext(); - ImGuiIO& io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls - - // Setup Dear ImGui style - ImGui::StyleColorsDark(); - - Device* device = m_renderer->GetDevice(); - const Swapchain* swapchain = m_renderer->GetSwapchain(); - VkDevice logicalDevice = device->GetLogicalDevice(); - const VkAllocationCallbacks* allocator = m_renderer->GetAllocator(); - - assert( device->GetQueueFamilyIndices().graphicsFamily.has_value() ); - - VkDescriptorPoolSize pool_sizes[] = { - { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, IMGUI_IMPL_VULKAN_MINIMUM_IMAGE_SAMPLER_POOL_SIZE }, - }; - VkDescriptorPoolCreateInfo pool_info = {}; - pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; - pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; - pool_info.maxSets = 0; - for( VkDescriptorPoolSize& pool_size : pool_sizes ) - pool_info.maxSets += pool_size.descriptorCount; - pool_info.poolSizeCount = (uint32_t)1; - pool_info.pPoolSizes = pool_sizes; - - CR( vkCreateDescriptorPool( device->GetLogicalDevice(), &pool_info, allocator, &m_descriptorPool ) ); - - // Setup Platform/Renderer backends - ImGui_ImplGlfw_InitForVulkan( window, true ); - ImGui_ImplVulkan_InitInfo init_info = {}; - init_info.Instance = m_renderer->GetVulkanInstance(); - init_info.PhysicalDevice = device->GetPhysicalDevice(); - init_info.Device = logicalDevice; - init_info.QueueFamily = device->GetQueueFamilyIndices().graphicsFamily.value(); - init_info.Queue = device->GetGraphicsQueue(); - init_info.DescriptorPool = m_descriptorPool; - init_info.MinImageCount = swapchain->GetMinImageCount(); - init_info.ImageCount = swapchain->GetImageCount(); - init_info.Allocator = allocator; - init_info.RenderPass = VK_NULL_HANDLE; - init_info.Subpass = 0; - init_info.MSAASamples = VK_SAMPLE_COUNT_1_BIT; - init_info.CheckVkResultFn = check_vk_result; - init_info.UseDynamicRendering = true; - - //dynamic rendering parameters for imgui to use - auto swapchainFormat = swapchain->GetFormat(); - init_info.PipelineRenderingCreateInfo = {}; - init_info.PipelineRenderingCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO; - init_info.PipelineRenderingCreateInfo.colorAttachmentCount = 1; - init_info.PipelineRenderingCreateInfo.pColorAttachmentFormats = &swapchainFormat; - init_info.PipelineRenderingCreateInfo.depthAttachmentFormat = m_renderer->GetDepthTexture()->GetFormat(); - init_info.PipelineRenderingCreateInfo.stencilAttachmentFormat = m_renderer->GetDepthTexture()->GetFormat(); - - ImGui_ImplVulkan_Init( &init_info ); - - state.windowSize.RegisterCallback( [this]( std::pair size, AppState& appState ) { - auto [width, height] = size; - m_graphicsCommandBuffer.SetRenderSize( width, height ); - } ); - - state.cmfContent.RegisterCallback( [this]( std::shared_ptr content, AppState& appState ) { - if( content == nullptr ) - { - m_loadStatus = LoadStatus::FAILED; - } - else - { - m_loadStatus = LoadStatus::SUCCESSFUL; - } - RegisterModelCallbacks( appState ); - } ); - - auto [width, height] = state.windowSize.GetValue(); - m_graphicsCommandBuffer.SetRenderSize( width, height ); - UpdateUiState( state ); -} - -void UIRenderer::RegisterModelCallbacks( AppState& appState ) -{ - // Register callbacks for model state changes - appState.modelState.currentAnimation.RegisterCallback( [this]( std::string animationName, AppState& appState ) { - m_playback.currentTime = 0.0f; - m_playback.playing = false; - - auto animationOwner = appState.modelState.activeAnimationOwner.GetValue(); - if( !animationName.empty() && animationOwner != nullptr ) - { - for( const auto& animation : animationOwner->m_cmfData->animations ) - { - if( cmf::ToStdString( animation.name ) == animationName ) - { - m_playback.duration = animation.duration; - return; - } - } - } - else - { - m_playback.duration = 0.0f; - } - } ); - - appState.modelState.activeAnimationOwner.RegisterCallback( [this]( std::shared_ptr activeAnimationOwner, AppState& appState ) { - appState.modelState.currentAnimation.SetValue( "" ); - appState.modelState.currentAnimationTime.SetValue( 0.0f ); - m_playback = Playback{}; - } ); -} - -const char* UIRenderer::FileOpenDialog() -{ - char const* filter[1] = { "*.cmf" }; - return tinyfd_openFileDialog( - "Open CMF File", - NULL, - 1, - filter, - "CMF Files", - 0 ); -} - -void UIRenderer::BeginFrame() -{ - // Start the Dear ImGui frame - ImGui_ImplVulkan_NewFrame(); - ImGui_ImplGlfw_NewFrame(); - ImGui::NewFrame(); -} - -void UIRenderer::Render( AppState& appState ) -{ - SetupMenubar( appState ); - SetupUi( appState ); - m_graphicsCommandBuffer.Begin( m_renderer.get() ); - ImGui::Render(); - - ImGui_ImplVulkan_RenderDrawData( ImGui::GetDrawData(), m_renderer->GetCurrentGraphicVkCommandBuffer() ); - m_graphicsCommandBuffer.End(); -} - -void UIRenderer::SetupUi( AppState& appState ) -{ - UpdateUiState( appState ); - if( m_showMainUI ) - { - CMFInfoWindow( appState ); - m_detailWindow.Render( appState, MENU_BAR_HEIGHT, ANIMATION_PLAYER_HEIGHT ); - - SetupPlaybackControls( appState ); - } - SetupPopupWindows( appState ); -} - -void UIRenderer::CMFInfoWindow( AppState& appState ) -{ - bool open = true; - float width = (float)appState.windowSize.GetValue().first; - float height = (float)appState.windowSize.GetValue().second; - - float ySize = std::max( 1.0f, height - MENU_BAR_HEIGHT - ANIMATION_PLAYER_HEIGHT + 1 ); // +1 so we get an overlap of the borders - - ImGui::SetNextWindowPos( ImVec2( 0, 18 ), ImGuiCond_Always ); - ImGui::SetNextWindowSizeConstraints( ImVec2( 0, ySize ), ImVec2( width, ySize ) ); - ImGui::SetNextWindowSize( ImVec2( width / 4.0f, ySize ), ImGuiCond_FirstUseEver ); - if( ImGui::Begin( "CMF Info", &open, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings ) ) - { - SetupGeneralView( appState ); - - SetupMeshListView( m_uiState.modelStates, appState ); - - SetupSkeletonOwners( m_uiState.skeletonOwners, appState ); - } - ImGui::End(); -} - -void UIRenderer::SetupGeneralView( AppState& appState ) -{ - ImGui::SeparatorText( "General" ); - if( ImGui::BeginTable( "##table", 2 ) ) - { - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Path" ); - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); - ImGui::InputText( "##label", m_uiState.filePath.data(), m_uiState.filePath.size(), ImGuiInputTextFlags_ReadOnly ); - ImGui::SetItemTooltip( "%s", m_uiState.filePath.data() ); - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Lod" ); - ImGui::TableNextColumn(); - SetupCombo( "##lod", m_uiState.modelStates.lod, appState.modelState.selectedLod ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Vertices" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u", m_uiState.modelStates.vertexCount ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Indices" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u", m_uiState.modelStates.indexCount ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Meshes" ); - ImGui::TableNextColumn(); - ImGui::Text( "%zu", m_uiState.modelStates.meshes.size() ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Polygon Mode" ); - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); - SetupCombo( "##polygonmode", m_uiState.polygonModeComboBox, appState.modelState.polygonMode ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Visualization" ); - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); - SetupCombo( "##visualiationMode", m_uiState.visualizationShaderComboBox, appState.modelState.activeShader ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Display" ); - ImGui::TableNextColumn(); - size_t checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { - return state.GetValue().display.GetValue(); - } ); - ImGui::CheckBoxTriStateValue checked = ( checkedCount == 0 ) ? ImGui::CheckBoxTriStateValue::UNCHECKED : ( checkedCount == appState.modelState.meshes.size() ? ImGui::CheckBoxTriStateValue::CHECKED : ImGui::CheckBoxTriStateValue::MIXED ); - OnChange( ImGui::CheckBoxTristate( "##displaycheckbox", &checked ), [&appState, &checked]() { - std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checked]( State& state ) { - state.GetValue().display.SetValue( checked == ImGui::CheckBoxTriStateValue::CHECKED ); - } ); - } ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Bounding Box" ); - ImGui::TableNextColumn(); - bool boundingBox = m_uiState.modelStates.boundingBox; - OnChange( ImGui::Checkbox( "##boundingboxcheckbox", &boundingBox ), [&appState, &boundingBox]() { - appState.modelState.modelBoundingBox.SetValue( boundingBox ); - } ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Wireframe Overlay" ); - ImGui::TableNextColumn(); - checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { - return state.GetValue().wireframeOverlay.GetValue(); - } ); - checked = ( checkedCount == 0 ) ? ImGui::CheckBoxTriStateValue::UNCHECKED : ( checkedCount == appState.modelState.meshes.size() ? ImGui::CheckBoxTriStateValue::CHECKED : ImGui::CheckBoxTriStateValue::MIXED ); - OnChange( ImGui::CheckBoxTristate( "##wireframecheckbox", &checked ), [&appState, &checked]() { - std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checked]( State& state ) { - state.GetValue().wireframeOverlay.SetValue( checked == ImGui::CheckBoxTriStateValue::CHECKED ); - } ); - } ); - ImGui::TableNextRow(); - - bool hasAudioOcclusionMeshes = std::any_of( m_uiState.modelStates.meshes.begin(), m_uiState.modelStates.meshes.end(), []( const MeshUiState& state ) { - return state.hasAudioOcclusionMesh; - } ); - ImGui::BeginDisabled( !hasAudioOcclusionMeshes ); - ImGui::TableNextColumn(); - ImGui::Text( "Audio Occlusion Mesh" ); - ImGui::TableNextColumn(); - checkedCount = std::count_if( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), []( const State& state ) { - return state.GetValue().audioOcclusionMesh.GetValue(); - } ); - checked = ( checkedCount == 0 ) ? ImGui::CheckBoxTriStateValue::UNCHECKED : ( checkedCount == appState.modelState.meshes.size() ? ImGui::CheckBoxTriStateValue::CHECKED : ImGui::CheckBoxTriStateValue::MIXED ); - OnChange( ImGui::CheckBoxTristate( "##audioocclusioncheckbox", &checked ), [&appState, &checked]() { - std::for_each( appState.modelState.meshes.begin(), appState.modelState.meshes.end(), [checked]( State& state ) { - state.GetValue().audioOcclusionMesh.SetValue( checked == ImGui::CheckBoxTriStateValue::CHECKED ); - } ); - } ); - ImGui::TableNextRow(); - ImGui::EndDisabled(); - - SetupModelAxisRows( appState ); - - ImGui::EndTable(); - } -} - -void UIRenderer::SetupMeshListView( const ModelUiState& modelState, AppState& appState ) -{ - bool open = true; - - if( !modelState.meshes.empty() ) - { - std::string header = "Meshes (" + std::to_string( modelState.meshes.size() ) + ")"; - if( ImGui::CollapsingHeader( header.c_str(), &open, ImGuiTreeNodeFlags_DefaultOpen ) ) - { - for( const auto& mesh : modelState.meshes ) - { - SetupMeshView( mesh, appState ); - } - } - } -} - -void UIRenderer::SetupModelAxisRows( AppState& appState ) -{ - std::vector>> allNormalStates; - std::vector>> allTangentStates; - std::vector>> allBinormalStates; - - for( const auto& meshState : appState.modelState.meshes ) - { - allNormalStates.push_back( meshState.GetValue().showVertexNormals ); - allTangentStates.push_back( meshState.GetValue().showVertexTangents ); - allBinormalStates.push_back( meshState.GetValue().showVertexBinormals ); - } - - auto normalCheckboxes = GetAxisTriCheckboxStates( allNormalStates ); - auto tangentCheckboxes = GetAxisTriCheckboxStates( allTangentStates ); - auto binormalCheckboxes = GetAxisTriCheckboxStates( allBinormalStates ); - - SetupModelAxisRow( normalCheckboxes, std::string( "Normals" ), [&appState]( bool checked, uint32_t index ) { - for( auto& meshState : appState.modelState.meshes ) - { - auto& vertexNormalStates = meshState.GetValue().showVertexNormals; - auto foundState = std::find_if( vertexNormalStates.begin(), vertexNormalStates.end(), [index]( const State>& state ) { - return state.GetValue().first == index; - } ); - if( foundState != vertexNormalStates.end() ) - { - foundState->SetValue( { index, checked } ); - } - } - } ); - - SetupModelAxisRow( tangentCheckboxes, std::string( "Tangents" ), [&appState]( bool checked, uint32_t index ) { - for( auto& meshState : appState.modelState.meshes ) - { - auto& vertexTangentStates = meshState.GetValue().showVertexTangents; - auto foundState = std::find_if( vertexTangentStates.begin(), vertexTangentStates.end(), [index]( const State>& state ) { - return state.GetValue().first == index; - } ); - if( foundState != vertexTangentStates.end() ) - { - foundState->SetValue( { index, checked } ); - } - } - } ); - - SetupModelAxisRow( binormalCheckboxes, std::string( "Bitangents" ), [&appState]( bool checked, uint32_t index ) { - for( auto& meshState : appState.modelState.meshes ) - { - auto& vertexBinormalStates = meshState.GetValue().showVertexBinormals; - auto foundState = std::find_if( vertexBinormalStates.begin(), vertexBinormalStates.end(), [index]( const State>& state ) { - return state.GetValue().first == index; - } ); - if( foundState != vertexBinormalStates.end() ) - { - foundState->SetValue( { index, checked } ); - } - } - } ); -} - -std::vector> UIRenderer::GetAxisTriCheckboxStates( const std::vector>>& axisStates ) -{ - std::vector> combinedStates; - - for( const auto& meshState : axisStates ) - { - for( const auto& axis : meshState ) - { - auto [index, checked] = axis.GetValue(); - - auto foundState = std::find_if( combinedStates.begin(), combinedStates.end(), [idx = index]( const std::pair& pair ) { - return pair.first == idx; - } ); - - if( foundState == combinedStates.end() ) - { - combinedStates.push_back( { index, checked ? ImGui::CheckBoxTriStateValue::CHECKED : ImGui::CheckBoxTriStateValue::UNCHECKED } ); - } - else - { - if( foundState->second != ImGui::CheckBoxTriStateValue::MIXED ) - { - if( ( foundState->second == ImGui::CheckBoxTriStateValue::CHECKED && !checked ) || ( foundState->second == ImGui::CheckBoxTriStateValue::UNCHECKED && checked ) ) - { - foundState->second = ImGui::CheckBoxTriStateValue::MIXED; - } - } - } - } - } - - return combinedStates; -} - -void UIRenderer::SetupVertexAxisRows( MeshState& meshAppState ) -{ - auto usageIndexChecker = []( const State>& pair1, const State>& pair2 ) { - return pair1.GetValue().first < pair2.GetValue().first; - }; - - auto maxNormalIndexState = std::max_element( meshAppState.showVertexNormals.begin(), meshAppState.showVertexNormals.end(), usageIndexChecker ); - auto maxTangentIndexState = std::max_element( meshAppState.showVertexTangents.begin(), meshAppState.showVertexTangents.end(), usageIndexChecker ); - auto maxBitangentIndexState = std::max_element( meshAppState.showVertexBinormals.begin(), meshAppState.showVertexBinormals.end(), usageIndexChecker ); - - int32_t maxNormalIndex = maxNormalIndexState != meshAppState.showVertexNormals.end() ? maxNormalIndexState->GetValue().first : -1; - int32_t maxTangentIndex = maxTangentIndexState != meshAppState.showVertexTangents.end() ? maxTangentIndexState->GetValue().first : -1; - int32_t maxBitangentIndex = maxBitangentIndexState != meshAppState.showVertexBinormals.end() ? maxBitangentIndexState->GetValue().first : -1; - - SetupVertexAxisRow( meshAppState.showVertexNormals, "Normals", maxNormalIndex ); - SetupVertexAxisRow( meshAppState.showVertexTangents, "Tangents", maxTangentIndex ); - SetupVertexAxisRow( meshAppState.showVertexBinormals, "Bitangents", maxBitangentIndex ); -} - -void UIRenderer::SetupVertexAxisRow( StateCollection>& vertexAxisStates, const char* label, int maxUsageIndex ) -{ - if( vertexAxisStates.empty() ) - { - ImGui::BeginDisabled(); - } - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::TextUnformatted( label ); - ImGui::TableNextColumn(); - - if( maxUsageIndex >= 0 ) - { - for( int32_t i = 0; i <= maxUsageIndex; ++i ) - { - // find the states that have the index of the column (f.ex there is nothing stopping a mesh having one color attribute that is using usageindex at 16) - auto foundElement = std::find_if( vertexAxisStates.begin(), vertexAxisStates.end(), [i]( const State>& state ) { - return state.GetValue().first == i; - } ); - - if( foundElement != vertexAxisStates.end() ) - { - bool changed = ImGui::Checkbox( ( std::string( "##" ) + label + std::to_string( i ) ).c_str(), &foundElement->GetValue().second ); - - OnChange( changed, [foundElement]() { - foundElement->SetValue( { foundElement->GetValue().first, foundElement->GetValue().second } ); - } ); - - ImGui::SetItemTooltip( "Toggles debug visualization for %s with usage index %d", label, i ); - ImGui::SameLine(); - } - else - { - ImGui::BeginDisabled( true ); - bool value = false; - ImGui::Checkbox( ( std::string( "##" ) + label + std::to_string( i ) ).c_str(), &value ); - ImGui::SetItemTooltip( "Mesh doesn't have %s with usage index %d.", label, i ); - ImGui::SameLine(); - ImGui::EndDisabled(); - } - } - ImGui::NewLine(); - } - if( vertexAxisStates.empty() ) - { - ImGui::EndDisabled(); - } -} - -void UIRenderer::SetupMeshView( const MeshUiState& mesh, AppState& appState ) -{ - if( ImGui::TreeNode( mesh.name.c_str() ) ) - { - if( ImGui::BeginTable( "##table", 2 ) ) - { - auto& meshAppState = appState.modelState.meshes[mesh.meshIndex].GetValue(); - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Vertex Count" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u", mesh.vertexCount ); - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Index Count" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u", mesh.indexCount ); - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Current Lod" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u/%u", mesh.lod, mesh.maxLodIndex ); - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Screen Size" ); - ImGui::SetItemTooltip( "The approximate screen size based on the generated bounding sphere with radius %.3f (if it is infinite then the camera is inside of the sphere)", mesh.boundingSphereRadius ); - ImGui::TableNextColumn(); - if( mesh.screenSize == std::numeric_limits::max() ) - { - ImGui::Text( "infinite" ); - } - else - { - ImGui::Text( "%upx", uint32_t( mesh.screenSize ) ); - } - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Display" ); - ImGui::SetItemTooltip( "Toggles the \"%s\" mesh", mesh.name.c_str() ); - - ImGui::TableNextColumn(); - bool display = mesh.display; - OnChange( ImGui::Checkbox( "##displaycheckbox", &display ), [&meshAppState, &display]() { - meshAppState.display.SetValue( display ); - } ); - - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Bounding Box" ); - ImGui::SetItemTooltip( "Toggles the bounding box for \"%s\" mesh", mesh.name.c_str() ); - - ImGui::TableNextColumn(); - bool boundingBox = mesh.boundingBox; - OnChange( ImGui::Checkbox( "##boundingboxcheckbox", &boundingBox ), [&meshAppState, &boundingBox]() { - meshAppState.renderBoundingBox.SetValue( boundingBox ); - } ); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Text( "Wireframe Overlay" ); - ImGui::SetItemTooltip( "Toggles the wireframe overlay for the \"%s\" mesh", mesh.name.c_str() ); - - ImGui::TableNextColumn(); - bool wireframeOverlay = mesh.wireframeOverlay; - OnChange( ImGui::Checkbox( "##wireframeoverlaycheckbox", &wireframeOverlay ), [&meshAppState, &wireframeOverlay]() { - meshAppState.wireframeOverlay.SetValue( wireframeOverlay ); - } ); - ImGui::TableNextRow(); - - ImGui::BeginDisabled( !mesh.hasAudioOcclusionMesh ); - ImGui::TableNextColumn(); - ImGui::Text( "Audio Occlusion Mesh" ); - ImGui::SetItemTooltip( "Toggles the audio occlusion mesh rendering for the \"%s\" mesh", mesh.name.c_str() ); - ImGui::TableNextColumn(); - bool audioOcclusion = mesh.audioOcclusionMesh; - OnChange( ImGui::Checkbox( "##audioocclusionmeshcheckbox", &audioOcclusion ), [&meshAppState, &audioOcclusion]() { - meshAppState.audioOcclusionMesh.SetValue( audioOcclusion ); - } ); - ImGui::EndDisabled(); - - SetupVertexAxisRows( meshAppState ); - - ImGui::EndTable(); - - std::string header = "Morph Targets (" + std::to_string( mesh.morphTargets.size() ) + ")"; - if( ImGui::CollapsingHeader( header.c_str() ) ) - { - for( const auto& morphTarget : mesh.morphTargets ) - { - SetupMorphTarget( morphTarget, mesh.meshIndex, appState ); - } - } - } - ImGui::TreePop(); - } -} - -void UIRenderer::SetupMorphTarget( const MorphTargetUiState& morphTarget, size_t meshIndex, AppState& appState ) -{ - ImGui::SeparatorText( morphTarget.name.c_str() ); - - if( ImGui::BeginTable( "##table", 3 ) ) - { - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed ); - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthStretch ); - ImGui::TableSetupColumn( "", ImGuiTableColumnFlags_WidthFixed, BUTTON_SIZE ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Weight" ); - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x ); - float weight = morphTarget.weight; - OnChange( ImGui::SliderFloat( "##slider", &weight, 0.0f, 1.0f, "%.6f" ), [&appState, &morphTarget, &weight, meshIndex]() { - auto& morphState = appState.modelState.meshes[meshIndex].GetValue().morphs[morphTarget.index]; - morphState.SetValue( { weight, morphState.GetValue().second } ); - } ); - ImGui::TableNextColumn(); - bool enabled = morphTarget.enabled; - - OnChange( ImGui::Checkbox( "##checkbox", &enabled ), [&appState, &morphTarget, &enabled, meshIndex]() { - auto& morphState = appState.modelState.meshes[meshIndex].GetValue().morphs[morphTarget.index]; - morphState.SetValue( { morphState.GetValue().first, enabled } ); - } ); - ImGui::SetItemTooltip( "Toggles the \"%s\" morph target", morphTarget.name.c_str() ); - ImGui::EndTable(); - } -} - -void UIRenderer::SetupSkeletonOwners( const std::vector& skeletonOwners, AppState& appState ) -{ - // static meshes - if( skeletonOwners.empty() ) - { - std::string header = "Skeletons ( 0 )"; - if( ImGui::CollapsingHeader( header.c_str(), ImGuiTreeNodeFlags_None ) ) - { - ImGui::BeginDisabled(); - ImGui::Button( "+", ImVec2( BUTTON_SIZE, BUTTON_SIZE ) ); - ImGui::SetItemTooltip( "Adds an animation override - Disabled since model doesn't have a skeleton" ); - ImGui::EndDisabled(); - } - return; - } - - // The first skeleton owner is the model, the rest are overrides - std::string header = "Skeletons ( " + std::to_string( skeletonOwners.size() ) + " )"; - if( ImGui::CollapsingHeader( header.c_str(), ImGuiTreeNodeFlags_DefaultOpen ) ) - { - // debug options show bones, joints and joint axis - ImGui::BeginTable( "##SkeletonDebug", 2 ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - - ImGui::Text( "Skeleton Joint" ); - ImGui::TableNextColumn(); - OnChange( ImGui::Checkbox( "##skeletonjoint", &m_uiState.jointDebug ), [&appState, this]() { - appState.modelState.jointDebug.SetValue( m_uiState.jointDebug ); - } ); - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - ImGui::Text( "Skeleton Joint Axis" ); - ImGui::TableNextColumn(); - OnChange( ImGui::Checkbox( "##skeletonjointaxis", &m_uiState.jointAxisDebug ), [&appState, this]() { - appState.modelState.jointAxisDebug.SetValue( m_uiState.jointAxisDebug ); - } ); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - - ImGui::Text( "Skeleton Bones" ); - ImGui::TableNextColumn(); - OnChange( ImGui::Checkbox( "##skeletonbones", &m_uiState.boneDebug ), [&appState, this]() { - appState.modelState.boneDebug.SetValue( m_uiState.boneDebug ); - } ); - ImGui::TableNextRow(); - ImGui::EndTable(); - - ImGui::SeparatorText( "Animation Owners" ); - OnChange( ImGui::Button( "+", ImVec2( ImGui::GetContentRegionAvail().x, BUTTON_SIZE ) ), [this, &appState]() { - auto* path = this->FileOpenDialog(); - if( path != nullptr ) - { - auto data = CmfContentLoader::LoadContentFromFile( path ); - if( data ) - { - appState.modelState.animationOverrides.AddState( data ); - } - } - } ); - - ImGui::SetItemTooltip( "Adds an animation owner from a cmf file" ); - - for( int32_t index = 0; index < skeletonOwners.size(); ++index ) - { - ImGui::PushID( index ); - - auto skeletonOwner = skeletonOwners[index]; - - if( ImGui::RadioButton( skeletonOwner.shortSource.c_str(), appState.modelState.activeAnimationOwner.GetValue() == skeletonOwner.cmfContent ) ) - { - appState.modelState.activeAnimationOwner.SetValue( skeletonOwner.cmfContent ); - } - - if( ImGui::BeginItemTooltip() ) - { - if( index == 0 ) - { - ImGui::Text( "Model skeleton and animations" ); - } - else - { - ImGui::Text( "Animation owner from %s", skeletonOwner.source.c_str() ); - } - ImGui::Text( "Skeletons" ); - for( const auto& skeleton : skeletonOwner.skeletons ) - { - ImGui::BulletText( "%s has %d bones", skeleton.name.c_str(), skeleton.bonesCount ); - } - ImGui::EndTooltip(); - } - - if( index != 0 ) - { - ImGui::SameLine( ImGui::GetContentRegionAvail().x - BUTTON_SIZE ); - OnChange( ImGui::Button( "-", ImVec2( BUTTON_SIZE, BUTTON_SIZE ) ), [&appState, skeletonOwner, index]() { - appState.modelState.animationOverrides.RemoveAt( index - 1 ); - - if( appState.modelState.activeAnimationOwner.GetValue() == skeletonOwner.cmfContent ) - { - appState.modelState.activeAnimationOwner.SetValue( appState.cmfContent.GetValue() ); - } - } ); - ImGui::SetItemTooltip( "Removes %s ", skeletonOwner.source.c_str() ); - } - - ImGui::PopID(); - } - } -} - -void UIRenderer::SetupSkeletons( const std::vector& skeletonStates, AppState& appState ) -{ - for( const auto& skeleton : skeletonStates ) - { - if( ImGui::TreeNode( skeleton.name.c_str() ) ) - { - if( ImGui::BeginTable( "##table", 3 ) ) - { - ImGui::TableNextRow(); - - ImGui::TableNextColumn(); - - ImGui::Text( "Bone Count" ); - ImGui::TableNextColumn(); - ImGui::Text( "%u", skeleton.bonesCount ); - } - ImGui::TreePop(); - } - } -} - -void UIRenderer::SetupMenubar( AppState& appState ) -{ - if( ImGui::BeginMainMenuBar() ) - { - if( ImGui::BeginMenu( "File" ) ) - { - if( ImGui::MenuItem( "Open", "Ctrl+O" ) ) - { - auto filePath = FileOpenDialog(); - if( filePath != nullptr ) - { - appState.ResetModelState(); - appState.cmfContent.ForceSetValue( CmfContentLoader::LoadContentFromFile( filePath ) ); - appState.cmfPath.SetValue( std::string( filePath ) ); - } - } - if( appState.cmfContent.GetValue() == nullptr ) - { - ImGui::BeginDisabled(); - } - if( ImGui::MenuItem( "Load Animation Override" ) ) - { - const auto* filePath = FileOpenDialog(); - if( filePath != nullptr ) - { - auto data = CmfContentLoader::LoadContentFromFile( filePath ); - if( data ) - { - appState.modelState.animationOverrides.AddState( data ); - } - } - } - if( appState.cmfContent.GetValue() == nullptr ) - { - ImGui::EndDisabled(); - } - - ImGui::Separator(); - if( ImGui::MenuItem( "Exit" ) ) - { - appState.exitRequested.SetValue( true ); - } - ImGui::EndMenu(); - } - if( ImGui::BeginMenu( "View" ) ) - { - if( ImGui::BeginMenu( "Camera" ) ) - { - if( ImGui::MenuItem( "Focus", "Ctrl+F" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_FOCUS ); - } - if( ImGui::MenuItem( "Look Right (+X)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_RIGHT ); - } - if( ImGui::MenuItem( "Look Left (-X)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_LEFT ); - } - if( ImGui::MenuItem( "Look Up (+Y)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_UP ); - } - if( ImGui::MenuItem( "Look Down (-Y)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_DOWN ); - } - if( ImGui::MenuItem( "Look Front (-Z)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_FRONT ); - } - if( ImGui::MenuItem( "Look Back (+Z)" ) ) - { - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_LOOK_BACK ); - } - - ImGui::EndMenu(); - } - ImGui::Separator(); - const char* toggleUiLabel = m_showMainUI ? "Hide UI" : "Show UI"; - if( ImGui::MenuItem( toggleUiLabel, "Ctrl+F12" ) ) - { - ToggleUiVisibility(); - } - ImGui::EndMenu(); - } - - ImGui::EndMainMenuBar(); - } -} - -void UIRenderer::ToggleUiVisibility() -{ - m_showMainUI = !m_showMainUI; -} - -const char* UIRenderer::GetPlaybackButtonLabel() const -{ - if( m_playback.playing ) - { - return "Pause"; - } - return "Play"; -} - -void UIRenderer::HandlePlaybackButtonPressed() -{ - m_playback.playing = !m_playback.playing; - if( m_playback.currentTime == m_playback.duration ) - { - m_playback.currentTime = 0.0f; - } -} - -void UIRenderer::StepAnimation( float amount, AppState& appState ) -{ - m_playback.playing = false; - // step a tenth of a sec - m_playback.currentTime += amount; - m_playback.currentTime = std::max( 0.0f, std::min( m_playback.currentTime, m_playback.duration ) ); - appState.modelState.currentAnimationTime.SetValue( m_playback.currentTime ); -} - -void UIRenderer::SetupPlaybackControls( AppState& appState ) -{ - float width = (float)appState.windowSize.GetValue().first; - float height = (float)appState.windowSize.GetValue().second; - - // animation player - ImGui::SetNextWindowPos( ImVec2( 0, height - ANIMATION_PLAYER_HEIGHT ), ImGuiCond_Always ); - ImGui::SetNextWindowSize( ImVec2( width, ANIMATION_PLAYER_HEIGHT ), ImGuiCond_Always ); - bool open = true; - if( ImGui::Begin( "Animation", &open, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize ) ) - { - ImGui::PushItemWidth( 120.0f ); - // selection box to the right - SetupCombo( "##animation", m_playback.animationComboBox, appState.modelState.currentAnimation ); - ImGui::SameLine(); - - bool disabled = m_playback.animationComboBox.selectedItemValue == ""; - if( disabled ) - { - ImGui::BeginDisabled(); - } - OnChange( ImGui::Button( "<", ImVec2( 18.0f, 18.0f ) ), [this, &appState]() { - StepAnimation( -0.1f, appState ); - } ); - - ImGui::SameLine(); - OnChange( ImGui::Button( GetPlaybackButtonLabel(), ImVec2( 64.0f, 18.0f ) ), [this]() { HandlePlaybackButtonPressed(); } ); - - ImGui::SameLine(); - OnChange( ImGui::Button( ">", ImVec2( 18.0f, 18.0f ) ), [this, &appState]() { - StepAnimation( 0.1f, appState ); - } ); - - ImGui::SameLine(); - ImGui::PushItemWidth( 30.0f ); - ImGui::Checkbox( "##repeatanimation", &m_playback.repeat ); - ImGui::SetItemTooltip( "Repeat the animation" ); - - ImGui::SameLine(); - float availableWidth = ImGui::GetContentRegionAvail().x; - ImGui::PushItemWidth( availableWidth ); - OnChange( ImGui::SliderFloat( "##playbackSlider", &m_playback.currentTime, 0.0f, m_playback.duration ), [&appState, this]() { - appState.modelState.currentAnimationTime.SetValue( m_playback.currentTime ); - } ); - ImGui::SetItemTooltip( "%.3fs / %.3fs", m_playback.currentTime, m_playback.duration ); - - if( disabled ) - { - ImGui::EndDisabled(); - } - ImGui::SameLine(); - } - ImGui::End(); -} - -void UIRenderer::SetupPopupWindows( AppState& appState ) -{ - if( m_loadStatus == LoadStatus::FAILED ) - { - ImGui::OpenPopup( "Error" ); - bool open = true; - ImVec2 center = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos( center, ImGuiCond_Appearing, ImVec2( 0.5f, 0.5f ) ); - - if( ImGui::BeginPopupModal( "Error", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse ) ) - { - auto filePath = appState.cmfPath.GetValue(); - ImGui::Text( "Failed to load" ); - ImGui::Text( "%s", filePath.c_str() ); - // At some point I can add the failure reason here... - ImGui::Separator(); - ImGui::NewLine(); - ImVec2 availableSize = ImGui::GetContentRegionAvail(); - ImGui::SameLine( availableSize.x / 2.0f - 50.0f ); - - if( ImGui::Button( "Ok", ImVec2( 100.0, 24 ) ) ) - { - m_loadStatus = LoadStatus::NOTHING_LOADED; - } - ImGui::EndPopup(); - } - } - else - { - m_loadStatus = LoadStatus::NOTHING_LOADED; - } -} - -void UIRenderer::Update( AppState& appstate ) -{ - UpdatePlayback( appstate ); - UpdateInputs( appstate ); -} - -void UIRenderer::UpdatePlayback( AppState& appState ) -{ - if( m_playback.playing ) - { - m_playback.currentTime += ImGui::GetIO().DeltaTime; - appState.modelState.currentAnimationTime.SetValue( m_playback.currentTime ); - if( m_playback.currentTime >= m_playback.duration ) - { - if( m_playback.repeat ) - { - m_playback.currentTime = 0.0f; - } - else - { - m_playback.currentTime = m_playback.duration; - m_playback.playing = false; - } - } - } -} - -void UIRenderer::UpdateInputs( AppState& appState ) -{ - auto io = ImGui::GetIO(); - if( !io.WantCaptureMouse ) - { - MouseState mouseState{}; - mouseState.position = { io.MousePos.x, io.MousePos.y }; - mouseState.previousPosition = { io.MousePosPrev.x, io.MousePosPrev.y }; - mouseState.wheelDelta = io.MouseWheel; - mouseState.leftButton = io.MouseDown[0]; - mouseState.middleButton = io.MouseDown[2]; - mouseState.rightButton = io.MouseDown[1]; - appState.mouseState.SetValue( mouseState ); - } - else - { - appState.mouseState.Reset(); - } - // Handle ui keyboard shortcuts - if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_O ) ) - { - // file open - auto filePath = FileOpenDialog(); - if( filePath != nullptr ) - { - appState.ResetModelState(); - appState.cmfContent.ForceSetValue( CmfContentLoader::LoadContentFromFile( filePath ) ); - appState.cmfPath.SetValue( std::string( filePath ) ); - } - } - if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_F ) ) - { - // focus camera on model - appState.cameraTrigger.ForceSetValue( CameraTrigger::CAMERA_TRIGGER_FOCUS ); - } - if( ImGui::IsKeyChordPressed( ImGuiMod_Ctrl | ImGuiKey_F12 ) ) - { - ToggleUiVisibility(); - } -} - -void UIRenderer::UpdateUiState( AppState& appState ) -{ - if( m_loadStatus == LoadStatus::SUCCESSFUL ) - { - // reset all appState items related to cmf - appState.modelState.selectedLod.SetValue( -1 ); - appState.modelState.currentAnimation.SetValue( "" ); - appState.modelState.currentAnimationTime.SetValue( 0.0f ); - m_playback = Playback{}; - } - - m_uiState = UiState{}; - auto cmfContent = appState.cmfContent.GetValue(); - if( cmfContent != nullptr ) - { - m_uiState.filePath = appState.cmfPath.GetValue(); - // polygon mode selection - m_uiState.polygonModeComboBox.items = { - { "Fill", VK_POLYGON_MODE_FILL }, - { "Line", VK_POLYGON_MODE_LINE }, - { "Point", VK_POLYGON_MODE_POINT } - }; - m_uiState.polygonModeComboBox.SetSelectedItemByValue( appState.modelState.polygonMode.GetValue() ); - - size_t meshIndex = 0; - size_t morphIndex = 0; - size_t maxLod = 0; - uint32_t skeletonIndex = 0; - - - m_uiState.jointDebug = appState.modelState.jointDebug.GetValue(); - m_uiState.jointAxisDebug = appState.modelState.jointAxisDebug.GetValue(); - m_uiState.boneDebug = appState.modelState.boneDebug.GetValue(); - - auto addSkeletonOwner = [this]( const std::string& source, const std::string& shortNameOverride, std::shared_ptr data ) { - SkeletonOwnerUiState skeletonOwnerState{}; - skeletonOwnerState.source = source; - skeletonOwnerState.cmfContent = data; - if( shortNameOverride.empty() ) - { - skeletonOwnerState.shortSource = source; - skeletonOwnerState.shortSource = skeletonOwnerState.shortSource.substr( skeletonOwnerState.shortSource.find_last_of( "/\\" ) + 1 ); - skeletonOwnerState.shortSource = skeletonOwnerState.shortSource.substr( skeletonOwnerState.shortSource.find_last_of( "//" ) + 1 ); - } - else - { - skeletonOwnerState.shortSource = shortNameOverride; - } - for( const auto& skeleton : data->m_cmfData->skeletons ) - { - SkeletonUiState skeletonState{}; - skeletonState.name = cmf::ToStdString( skeleton.name ); - skeletonState.bonesCount = static_cast( skeleton.bones.size() ); - skeletonOwnerState.skeletons.push_back( skeletonState ); - } - m_uiState.skeletonOwners.push_back( skeletonOwnerState ); - }; - - // Add skeleton owners - addSkeletonOwner( cmfContent->m_filePath, "Model Skeleton", cmfContent ); - for( const auto& animationOverride : appState.modelState.animationOverrides ) - { - addSkeletonOwner( animationOverride.GetValue()->m_filePath, "", animationOverride.GetValue() ); - } - - for( const auto& mesh : cmfContent->m_cmfData->meshes ) - { - if( meshIndex >= appState.modelState.meshes.size() ) - { - break; - } - const auto& meshAppState = appState.modelState.meshes[meshIndex].GetValue(); - MeshUiState meshState{}; - meshState.meshIndex = meshIndex; - meshState.name = cmf::ToStdString( mesh.name ); - meshState.lod = meshAppState.activeLod.GetValue(); - meshState.maxLodIndex = static_cast( mesh.lods.size() ) - 1; - meshState.boundingSphereRadius = CcpMath::Sphere( mesh.bounds ).radius; - meshState.screenSize = meshAppState.meshScreenSize.GetValue(); - meshState.display = meshAppState.display.GetValue(); - meshState.wireframeOverlay = meshAppState.wireframeOverlay.GetValue(); - meshState.audioOcclusionMesh = meshAppState.audioOcclusionMesh.GetValue(); - meshState.hasAudioOcclusionMesh = !mesh.audioOcclusionMesh.vertices.empty() && !mesh.audioOcclusionMesh.indices.empty(); - meshState.boundingBox = meshAppState.renderBoundingBox.GetValue(); - - maxLod = std::max( maxLod, mesh.lods.size() ); - meshState.vertexCount = 0; - meshState.indexCount = 0; - if( meshState.lod < mesh.lods.size() ) - { - const auto& lod = mesh.lods[meshState.lod]; - if( lod.vb.stride > 0 ) - { - meshState.vertexCount = cmf::GetStreamElementCount( lod.vb ); - } - if( lod.ib.stride > 0 ) - { - meshState.indexCount = cmf::GetStreamElementCount( lod.ib ); - } - } - - m_uiState.modelStates.vertexCount += meshState.vertexCount; - m_uiState.modelStates.indexCount += meshState.indexCount; - - for( size_t i = 0; i < mesh.morphTargets.targets.size(); ++i ) - { - if( i >= meshAppState.morphs.size() ) - { - break; - } - const auto& morphTarget = mesh.morphTargets.targets[i]; - const auto& morphPair = meshAppState.morphs[i].GetValue(); - MorphTargetUiState morphTargetState{}; - morphTargetState.name = cmf::ToStdString( morphTarget.name ); - morphTargetState.weight = morphPair.first; - morphTargetState.enabled = morphPair.second; - morphTargetState.index = i; - meshState.morphTargets.push_back( morphTargetState ); - } - - m_uiState.modelStates.meshes.push_back( meshState ); - meshIndex++; - } - - // visualization shader selection - for( const auto& shaderSetupInfo : appState.modelState.availableShaders.GetValue() ) - { - std::string shaderName = shaderSetupInfo.first; - if( !shaderSetupInfo.second.shaderNameAddition.empty() ) - { - shaderName += " " + shaderSetupInfo.second.shaderNameAddition; - } - - m_uiState.visualizationShaderComboBox.items.push_back( { shaderName, shaderSetupInfo } ); - } - m_uiState.visualizationShaderComboBox.SetSelectedItemByValue( appState.modelState.activeShader.GetValue() ); - - m_uiState.modelStates.lod.items.push_back( std::make_pair( "Auto", -1 ) ); - - for( uint32_t lod = 0; lod < maxLod; ++lod ) - { - m_uiState.modelStates.lod.items.push_back( std::make_pair( "Lod " + std::to_string( lod ), lod ) ); - } - - m_uiState.modelStates.lod.SetSelectedItemByValue( appState.modelState.selectedLod.GetValue() ); - m_uiState.modelStates.boundingBox = appState.modelState.modelBoundingBox.GetValue(); - - if( m_playback.animationComboBox.items.empty() ) - { - m_playback.animationComboBox.items.push_back( std::make_pair( "Rest Pose", "" ) ); - auto animationOwner = appState.modelState.activeAnimationOwner.GetValue(); - if( animationOwner ) - { - cmf::Data* activeData = animationOwner->m_cmfData; - - for( const auto& animation : activeData->animations ) - { - auto animationName = cmf::ToStdString( animation.name ); - m_playback.animationComboBox.items.push_back( std::make_pair( animationName, animationName ) ); - if( appState.modelState.currentAnimation.GetValue() == animationName ) - { - m_playback.duration = animation.duration; - } - } - m_playback.animationComboBox.SetSelectedItemByValue( appState.modelState.currentAnimation.GetValue() ); - } - } - } - else - { - // default uninitialized values - if( m_playback.animationComboBox.items.empty() ) - { - m_playback.animationComboBox.items.push_back( std::make_pair( "Rest Pose", "" ) ); - m_playback.animationComboBox.SetSelectedItemByValue( appState.modelState.currentAnimation.GetValue() ); - } - - // polygon mode selection - m_uiState.polygonModeComboBox.items = { - { "Fill", VK_POLYGON_MODE_FILL }, - { "Line", VK_POLYGON_MODE_LINE }, - { "Point", VK_POLYGON_MODE_POINT } - }; - m_uiState.polygonModeComboBox.SetSelectedItemByValue( appState.modelState.polygonMode.GetValue() ); - m_uiState.filePath = "No file loaded"; - - // visualization shader selection - m_uiState.visualizationShaderComboBox.items.push_back( { "", {} } ); - } -} - -void UIRenderer::OnChange( bool changed, std::function callback ) -{ - if( changed ) - { - callback(); - } -} - -// NOLINTEND(cppcoreguidelines-pro-type-vararg) \ No newline at end of file diff --git a/src/viewer/rendering/uiRenderer.h b/src/viewer/rendering/uiRenderer.h deleted file mode 100644 index 9676ddc..0000000 --- a/src/viewer/rendering/uiRenderer.h +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright © 2026 CCP ehf. - -#include - -// used by the template file -#include -#include - -#include "appState.h" -#include "rendering/renderer.h" -#include "uiDetailWindow.h" -#include "vulkan/commandbuffer.h" - -namespace ImGui -{ -enum class CheckBoxTriStateValue -{ - UNCHECKED = 0, - CHECKED = 1, - MIXED = -1 -}; -bool CheckBoxTristate( const char* label, CheckBoxTriStateValue* v_tristate ); -} - -// Handles rendering the UI -class UIRenderer -{ -public: - UIRenderer( std::shared_ptr ); - ~UIRenderer(); - - void Initialize( GLFWwindow* window, AppState& appState ); - void BeginFrame(); - - void Render( AppState& appState ); - - void SetupUi( AppState& appState ); - void CMFInfoWindow( AppState& appState ); - void SetupMenubar( AppState& appState ); - void Update( AppState& state ); - -private: - void UpdateInputs( AppState& state ); - void UpdatePlayback( AppState& appState ); - - template - struct CmfUiComboBox - { - std::string selectedItemName; - T selectedItemValue; - std::vector> items; - - void SetSelectedItemByValue( T value ); - }; - - enum LoadStatus - { - FAILED, - SUCCESSFUL, - NOTHING_LOADED - }; - - struct MorphTargetUiState - { - std::string name{ "" }; - float weight{ 0.0f }; - bool enabled{ true }; - size_t index; - }; - - struct MeshUiState - { - std::string name; - size_t meshIndex{ 0 }; - uint32_t lod{ 0 }; - uint32_t maxLodIndex{ 0 }; - float screenSize{ 0 }; - float boundingSphereRadius{ 0 }; - uint32_t vertexCount{ 0 }; - uint32_t indexCount{ 0 }; - std::vector morphTargets; - bool display{ true }; - bool boundingBox{ false }; - bool wireframeOverlay{ false }; - bool audioOcclusionMesh{ false }; - bool hasAudioOcclusionMesh{ false }; - std::vector> showVertexNormals{}; - std::vector> showVertexTangents{}; - std::vector> showVertexBinormals{}; - }; - - struct ModelUiState - { - std::vector meshes; - CmfUiComboBox lod; - uint32_t vertexCount{ 0 }; - uint32_t indexCount{ 0 }; - bool boundingBox{ false }; - }; - - struct SkeletonUiState - { - uint32_t bonesCount{ 0 }; - std::string name; - }; - - struct SkeletonOwnerUiState - { - std::string source; - std::string shortSource; - std::shared_ptr cmfContent; - std::vector skeletons; - }; - - struct UiState - { - std::string filePath; - ModelUiState modelStates{}; - std::vector skeletonOwners{}; - CmfUiComboBox polygonModeComboBox; - CmfUiComboBox> visualizationShaderComboBox; - bool boneDebug{ false }; - bool jointDebug{ false }; - bool jointAxisDebug{ false }; - }; - - struct Playback - { - float duration{ 0.0f }; - float currentTime{ 0.0f }; - bool playing{ false }; - bool repeat{ false }; - CmfUiComboBox animationComboBox; - }; - - void RegisterModelCallbacks( AppState& appState ); - - void SetupGeneralView( AppState& appState ); - void SetupMeshListView( const ModelUiState& modelState, AppState& appState ); - void SetupMeshView( const MeshUiState& mesh, AppState& appState ); - void SetupModelAxisRows( AppState& appState ); - - template - void SetupModelAxisRow( std::vector>& checkboxStates, const std::string& name, const Callable& changeCallback ); - - std::vector> GetAxisTriCheckboxStates( const std::vector>>& axisStates ); - void SetupVertexAxisRows( MeshState& meshAppState ); - void SetupVertexAxisRow( StateCollection>& vertexAxisStates, const char* label, int columnCount ); - void SetupMorphTarget( const MorphTargetUiState& morphTarget, size_t meshIndex, AppState& appState ); - void SetupSkeletonOwners( const std::vector& skeletonOwners, AppState& appState ); - void SetupSkeletons( const std::vector& skeletonStates, AppState& appState ); - void SetupPlaybackControls( AppState& appState ); - void SetupPopupWindows( AppState& appState ); - - const char* GetPlaybackButtonLabel() const; - void HandlePlaybackButtonPressed(); - void StepAnimation( float amount, AppState& appState ); - - void OnChange( bool changed, std::function callback ); - - template - void SetupCombo( const char* name, UIRenderer::CmfUiComboBox& combo, State& applicableState ); - - void UpdateUiState( AppState& appState ); - void ToggleUiVisibility(); - const char* FileOpenDialog(); - - VkDescriptorPool m_descriptorPool{ VK_NULL_HANDLE }; - - std::shared_ptr m_renderer; - GraphicsCommandBuffer m_graphicsCommandBuffer; - - UiState m_uiState{}; - bool m_showMainUI{ true }; - LoadStatus m_loadStatus{ NOTHING_LOADED }; - Playback m_playback{}; - UIDetailWindow m_detailWindow{}; -}; - - -#include "uiRenderer_template_impl.h" \ No newline at end of file diff --git a/src/viewer/rendering/uiRenderer_template_impl.h b/src/viewer/rendering/uiRenderer_template_impl.h deleted file mode 100644 index 4a6b865..0000000 --- a/src/viewer/rendering/uiRenderer_template_impl.h +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright © 2026 CCP ehf. - -template -void UIRenderer::SetupCombo( const char* name, UIRenderer::CmfUiComboBox& combo, State& applicableState ) -{ - if( ImGui::BeginCombo( name, combo.selectedItemName.c_str() ) ) - { - for( const auto& nameValue : combo.items ) - { - bool is_selected = ( nameValue.second == combo.selectedItemValue ); - - if( ImGui::Selectable( nameValue.first.c_str(), is_selected ) ) - { - combo.SetSelectedItemByValue( nameValue.second ); - applicableState.SetValue( nameValue.second ); - } - - if( is_selected ) - { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } -} - -template -void UIRenderer::CmfUiComboBox::SetSelectedItemByValue( T value ) -{ - for( const auto& item : items ) - { - if( item.second == value ) - { - selectedItemName = item.first; - selectedItemValue = item.second; - return; - } - } - if( !items.empty() ) - { - selectedItemName = items.front().first; - selectedItemValue = items.front().second; - } -} - -template -void UIRenderer::SetupModelAxisRow( std::vector>& checkboxStates, const std::string& name, const Callable& changeCallback ) -{ - int32_t maxIndex = checkboxStates.empty() ? -1 : std::max_element( checkboxStates.begin(), checkboxStates.end(), []( const std::pair& a, const std::pair& b ) { - return a.first < b.first; - } )->first; - - ImGui::BeginDisabled( maxIndex < 0 ); - ImGui::TableNextColumn(); - ImGui::Text( "%s", name.c_str() ); - ImGui::SetItemTooltip( "Toggles the %s visualization for all meshes", name.c_str() ); - ImGui::TableNextColumn(); - - if( maxIndex >= 0 ) - { - for( int32_t i = 0; i <= maxIndex; ++i ) - { - auto foundState = std::find_if( checkboxStates.begin(), checkboxStates.end(), [i]( const std::pair& pair ) { - return pair.first == (uint32_t)i; - } ); - - if( foundState == checkboxStates.end() ) - { - // add a disabled checkbox - ImGui::BeginDisabled( true ); - auto disabledValue = ImGui::CheckBoxTriStateValue::UNCHECKED; - ImGui::CheckBoxTristate( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &disabledValue ); - - ImGui::SetItemTooltip( "No mesh has %s information for usage %d", name.c_str(), i ); - ImGui::SameLine(); - ImGui::EndDisabled(); - } - else - { - auto value = foundState->second; - OnChange( ImGui::CheckBoxTristate( ( std::string( "##tricheckbox" ) + name + std::to_string( i ) ).c_str(), &value ), [changeCallback, i, value]() { - changeCallback( value != ImGui::CheckBoxTriStateValue::CHECKED, i ); - } ); - ImGui::SetItemTooltip( "Toggles debug visualization for %s with usage index %d for all meshes (if available)", name.c_str(), i ); - ImGui::SameLine(); - } - } - } - ImGui::EndDisabled(); -} \ No newline at end of file diff --git a/src/viewer/rendering/vulkan/graphicseffect.cpp b/src/viewer/rendering/vulkan/graphicseffect.cpp index eacdd00..34104ec 100644 --- a/src/viewer/rendering/vulkan/graphicseffect.cpp +++ b/src/viewer/rendering/vulkan/graphicseffect.cpp @@ -14,6 +14,16 @@ GraphicsEffect::GraphicsEffect( std::shared_ptr renderer ) : void GraphicsEffect::SetConfig( GraphicsEffectTypes::Config config ) { m_config = config; + if( m_initialized ) + { + m_initialized = RecreatePipeline() == VK_SUCCESS; + } +} + +void GraphicsEffect::Reset( std::string name, GraphicsEffectTypes::Config config ) +{ + m_config = config; + m_shaderName = name; if( m_initialized ) { diff --git a/src/viewer/rendering/vulkan/graphicseffect.h b/src/viewer/rendering/vulkan/graphicseffect.h index 4e49616..e69bbf4 100644 --- a/src/viewer/rendering/vulkan/graphicseffect.h +++ b/src/viewer/rendering/vulkan/graphicseffect.h @@ -16,6 +16,7 @@ class GraphicsEffect : public Effect Matrix view; }; + void Reset( std::string name, GraphicsEffectTypes::Config config ); void SetConfig( GraphicsEffectTypes::Config config ); void Bind( VkCommandBuffer commandBuffer, uint32_t currentFrameIndex ) override; size_t GetStride() const; diff --git a/src/viewer/scripts/fontAwesomeCreator.py b/src/viewer/scripts/fontAwesomeCreator.py new file mode 100644 index 0000000..bcab548 --- /dev/null +++ b/src/viewer/scripts/fontAwesomeCreator.py @@ -0,0 +1,278 @@ +# Copyright © 2026 CCP ehf. + +"""Generate ImGui-readable Font Awesome headers. + +This script has two purposes: +1) Generate a font byte-data header consumable by Dear ImGui. +2) Generate a Font Awesome icon define header for use in C++ UI code. + +The output style intentionally mirrors the spirit of +https://github.com/ocornut/imgui/blob/master/misc/fonts/binary_to_compressed_c.cpp +by emitting a `unsigned int` data array and explicit byte size. +""" + +import json +import re +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + + +def _find_repo_root(): + """Resolve repository root from this script location.""" + return Path(__file__).resolve().parents[3] + + +def _chunked(values, chunk_size): + """ + Yield successive `chunk_size`-sized chunks from `values`. + @param values: An iterable of values to chunk. + @param chunk_size: The size of each chunk to yield. + @return: An iterator of lists, each containing up to `chunk_size` elements from `values`. + """ + chunk = [] + for value in values: + chunk.append(value) + if len(chunk) == chunk_size: + yield chunk + chunk = [] + if chunk: + yield chunk + + +def _bytes_to_u32_words(data): + """ + Convert a bytes object into a list of 32-bit unsigned integers (words). + @param data: A bytes object to convert. + @return: A list of 32-bit unsigned integers representing the input bytes. + """ + words = [] + for i in range(0, len(data), 4): + block = data[i : i + 4] + value = 0 + for byte_index, byte_value in enumerate(block): + value |= byte_value << (byte_index * 8) + words.append(value) + return words + + +def _format_word_array(words, per_line = 12): + """ + Format a list of 32-bit unsigned integers as a C-style array. + @param words: A list of 32-bit unsigned integers to format. + @param per_line: The number of elements per line in the output array. + @return: A string representing the formatted C-style array. + """ + formatted_lines = [] + for chunk in _chunked(words, per_line): + joined = ", ".join(f"0x{value:08x}" for value in chunk) + formatted_lines.append(f" {joined},") + return "\n".join(formatted_lines) + + +def _sanitize_macro_suffix(icon_name): + """ + Sanitize an icon name to be used as a C++ macro suffix. + @param icon_name: The original icon name. + @return: A sanitized string suitable for use as a macro suffix. + """ + upper = icon_name.upper().replace("-", "_") + upper = re.sub(r"[^A-Z0-9_]", "_", upper) + upper = re.sub(r"_+", "_", upper).strip("_") + return upper if upper else "UNKNOWN" + + +def _codepoint_to_utf8_hex_escapes(codepoint): + """ + Convert a Unicode codepoint to a string of UTF-8 hex escapes. + @param codepoint: The Unicode codepoint to convert. + @return: A string of UTF-8 hex escapes representing the codepoint. + """ + encoded = chr(codepoint).encode("utf-8") + return "".join(f"\\x{byte:02x}" for byte in encoded) + + +def _format_ratio(width, height): + """ + Format the ratio of width to height as a C-style float literal. + @param width: The width value. + @param height: The height value. + @return: A string representing the ratio as a C-style float literal. + """ + if height == 0: + return "1.0f" + + ratio = width / height + formatted = f"{ratio:.6g}" + if "." not in formatted: + formatted += ".0" + return f"{formatted}f" + + +def _read_icon_size(payload): + """ + Read the size of an icon from its payload. + @param payload: The icon payload dictionary. + @return: A tuple containing the width and height of the icon. + """ + svg_payload = payload.get("svg") + if not isinstance(svg_payload, dict): + return 0.0, 0.0 + + solid_payload = svg_payload.get("solid") + if not isinstance(solid_payload, dict): + return 0.0, 0.0 + + width = solid_payload.get("width") + height = solid_payload.get("height") + if isinstance(width, (int, float)) and isinstance(height, (int, float)): + return float(width), float(height) + + view_box = solid_payload.get("viewBox") + if isinstance(view_box, list) and len(view_box) >= 4: + view_box_width = view_box[2] + view_box_height = view_box[3] + + return float(view_box_width), float(view_box_height) + + return 0.0, 0.0 + + +def _load_icons(icons_json_path): + """ + Load and filter icons from a Font Awesome icons.json file. + @param icons_json_path: The path to the icons.json file. + @return: A list of tuples containing icon name, codepoint, width, and height. + """ + with icons_json_path.open("r", encoding="utf-8") as input_file: + icon_data: Dict[str, Dict[str, object]] = json.load(input_file) + + icons: List[Tuple[str, int, float, float]] = [] + for icon_name, payload in icon_data.items(): + styles = payload.get("styles", []) + unicode_hex = payload.get("unicode") + if "solid" not in styles or not isinstance(unicode_hex, str): + continue + + codepoint = int(unicode_hex, 16) + width, height = _read_icon_size(payload) + icons.append((icon_name, codepoint, width, height)) + + icons.sort(key=lambda item: item[0]) + return icons + + +def _build_font_header(ttf_bytes, ttf_path): + """ + Build a C++ header string containing the TTF font data as a byte array. + @param ttf_bytes: The bytes of the TTF font file. + @param ttf_path: The path to the TTF font file. + @return: A string representing the C++ header with the font data. + """ + words = _bytes_to_u32_words(ttf_bytes) + array_body = _format_word_array(words) + + return ( + "// Auto-generated by fontAwesomeCreator.py.\n" + "// Source TTF: " + f"{ttf_path.as_posix()}\n" + "// Dear ImGui usage example:\n" + "// io.Fonts->AddFontFromMemoryTTF(\n" + "// const_cast(fa_solid_900_ttf_data),\n" + "// static_cast(fa_solid_900_ttf_size),\n" + "// 16.0f, &config, iconRanges);\n" + "#pragma once\n\n" + "static const unsigned int fa_solid_900_ttf_size = " + f"{len(ttf_bytes)}u;\n" + f"static const unsigned int fa_solid_900_ttf_data[{len(words)}] =\n" + "{\n" + f"{array_body}\n" + "};\n" + ) + + +def _build_icon_define_header(icons, icons_path): + """ + Build a C++ header string defining Font Awesome icons with their UTF-8 representations and aspect ratios. + @param icons: A list of tuples containing icon name, codepoint, width, and height. + @param icons_path: The path to the icons.json file. + @return: A string representing the C++ header with icon definitions. + """ + if not icons: + raise ValueError("No solid icons were found in the input icons metadata.") + + min_cp = min(codepoint for _, codepoint, _, _ in icons) + max_cp = max(codepoint for _, codepoint, _, _ in icons) + + lines: List[str] = [ + "// Auto-generated by fontAwesomeCreator.py.", + f"// Source icons.json: {icons_path.as_posix()}", + "#pragma once", + "", + f"#define ICON_MIN_FA 0x{min_cp:04x}", + f"#define ICON_MAX_FA 0x{max_cp:04x}", + "static const unsigned short ICON_RANGE_FA[] = { ICON_MIN_FA, ICON_MAX_FA, 0 };", + "", + "struct FaIcon", + "{", + "\tconst char* text;", + "\tfloat xyRatio;", + "};", + "", + ] + + for icon_name, codepoint, width, height in icons: + macro_suffix = _sanitize_macro_suffix(icon_name) + utf8_escaped = _codepoint_to_utf8_hex_escapes(codepoint) + ratio = _format_ratio(width, height) + lines.append(f"static const FaIcon ICON_FA_{macro_suffix} = {{ \"{utf8_escaped}\", {ratio} }};") + + lines.append("") + return "\n".join(lines) + + +def _parse_args(argv): + """ + Supported forms: + - fontAwesomeCreator.py + """ + if len(argv) == 5: + input_ttf = Path(argv[1]).resolve() + icons_json = Path(argv[2]).resolve() + output_font_header = Path(argv[3]).resolve() + output_define_header = Path(argv[4]).resolve() + return input_ttf, icons_json, output_font_header, output_define_header + + usage = ( + "Usage:\n" + " python fontAwesomeCreator.py " + ) + raise ValueError(usage) + + +def main(argv): + try: + input_ttf, icons_json, out_font_header, out_define_header = _parse_args(argv) + + if not input_ttf.is_file(): + raise FileNotFoundError(f"Input TTF not found: {input_ttf}") + if not icons_json.is_file(): + raise FileNotFoundError(f"Input icons JSON not found: {icons_json}") + + out_font_header.parent.mkdir(parents=True, exist_ok=True) + out_define_header.parent.mkdir(parents=True, exist_ok=True) + + ttf_bytes = input_ttf.read_bytes() + icons = _load_icons(icons_json) + + out_font_header.write_text(_build_font_header(ttf_bytes, input_ttf), encoding="utf-8") + out_define_header.write_text(_build_icon_define_header(icons, icons_json), encoding="utf-8") + except Exception as exc: # broad catch to keep the generator easy to diagnose from CMake logs + print(str(exc)) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/viewer/scripts/shaderCacheCreator.py b/src/viewer/scripts/shaderCacheCreator.py index 41fb8f0..66c1f9c 100644 --- a/src/viewer/scripts/shaderCacheCreator.py +++ b/src/viewer/scripts/shaderCacheCreator.py @@ -1,118 +1,167 @@ # Copyright © 2025 CCP ehf. -import sys import os import re +import sys + + +VERTEX_DECLARATION_PATTERN = re.compile( + r"^\s*layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*in\s+\w+\s+(\w+)\s*;" +) + +MULTI_USAGE_VERTEX_NAME = re.compile( + r"^\s*#pragma\s+multi_usage\s+(\w+)\s*$" +) + +SHADER_USAGE_LOOKUP = { + "inposition": "cmf::Usage::Position", + "innormal": "cmf::Usage::Normal", + "inbinormal": "cmf::Usage::Binormal", + "intangent": "cmf::Usage::Tangent", + "inpackedtangents": "cmf::Usage::PackedTangent", + "inpackedtangentslegacy": "cmf::Usage::PackedTangentLegacy", + "incolor": "cmf::Usage::Color", + "intexcoord": "cmf::Usage::TexCoord", + "inboneindices": "cmf::Usage::BoneIndices", + "inboneweights": "cmf::Usage::BoneWeights", +} + + +def shader_wrapper(shader_name): + return 'Shader( {\n #include "%s"\n } )' % shader_name + + +def parse_vertex_declarations(shader_path): + declarations = [] + multi_usage_names = [] + + with open(shader_path, "r") as vertex_shader: + for line in vertex_shader: + multi_usage_names_match = MULTI_USAGE_VERTEX_NAME.match(line) + if multi_usage_names_match: + multi_usage_names.append(multi_usage_names_match.group(1).lower()) + continue + + match = VERTEX_DECLARATION_PATTERN.match(line) + if not match: + continue + + + variable_name = match.group(2).lower() + if variable_name not in SHADER_USAGE_LOOKUP: + raise KeyError( + "could not find '%s' in applicable vertex variable names %s" + % (variable_name, SHADER_USAGE_LOOKUP.keys()) + ) + + declarations.append((int(match.group(1)), SHADER_USAGE_LOOKUP[variable_name], variable_name in multi_usage_names)) + + if len(multi_usage_names) > 1: + print("ERROR: Shader '%s' has multi usage index elements: %s" % (os.path.basename(shader_path), multi_usage_names)) + print("Only zero or one elements can be multi index usage") + sys.exit(1) + + return declarations + + +def collect_vertex_declarations(source_dir): + vertex_declarations = {} + + for filename in sorted(os.listdir(source_dir)): + if filename.endswith(".vert"): + shader_name = filename.rsplit(".", 1)[0] + vertex_declarations[shader_name] = parse_vertex_declarations(os.path.join(source_dir, filename)) + + return vertex_declarations -def enumerate_files( sourceDir, binaryDir, output): - shaderGroups = {} - vertexDeclarations = {} - - # matches the pattern: - # layout( location = 0 ) in vec3 inPosition; - # grabs the 0 and inPosition - pattern = r'layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*in\s+\w+\s+(\w+);' - - shaderUsageLookup = { - "inposition": "cmf::Usage::Position", - "innormal": "cmf::Usage::Normal", - "inbinormal": "cmf::Usage::Binormal", - "intangent": "cmf::Usage::Tangent", - "inpackedtangents": "cmf::Usage::PackedTangent", - "inpackedtangentslegacy": "cmf::Usage::PackedTangentLegacy", - "incolor": "cmf::Usage::Color", - "intexcoord": "cmf::Usage::TexCoord", - "inboneindices": "cmf::Usage::BoneIndices", - "inboneweights": "cmf::Usage::BoneWeights" - }; - - # find all the input vertex declarations of vertex shaders - for filename in os.listdir(sourceDir): - if not filename.endswith('.vert'): + +def collect_shader_groups(binary_dir): + shader_groups = {} + + for filename in sorted(os.listdir(binary_dir)): + if not filename.endswith(".spv.h"): continue - - parts = filename.split('.') - name = parts[0] - - with open(os.path.join(sourceDir, filename), 'r') as vertexShader: - inputDeclarations = [] - for line in vertexShader: - match = re.search(pattern, line) - if match: - variableName = str(match.group(2)).lower() - try: - usage = shaderUsageLookup[variableName] - except KeyError as e: - print("could not find '%s' in applicable vertex variable names %s" % ( variableName, shaderUsageLookup.keys() )) - raise e - - inputDeclarations.append( ( int(match.group(1)), usage )) - vertexDeclarations[name] = inputDeclarations - for filename in os.listdir(binaryDir): - if not filename.endswith('.spv.h'): + + parts = filename.split(".") + if len(parts) < 3: continue - parts = filename.split('.') - name = parts[0] - shaderType = parts[1] - shaders = shaderGroups.get(name, [None, None, None]) - if shaderType == 'vert': - shaders[0] = filename - elif shaderType == 'frag': - shaders[1] = filename - elif shaderType == 'comp': - shaders[2] = filename - - shaderGroups[name] = shaders - - code = "" - - for name, shaders in shaderGroups.items(): - - if (not shaders[0] or not shaders[1]) and not shaders[2]: - print("Warning: Shader group '%s' is missing a shader." % name) + shader_name = parts[0] + shader_type = parts[1] + group = shader_groups.setdefault(shader_name, {"vert": None, "frag": None, "comp": None}) + + if shader_type in group: + group[shader_type] = filename + + return shader_groups + + +def display_name(shader_name): + if not shader_name.startswith("model_"): + return shader_name + + parts = shader_name[len("model_"):].split("_") + return " ".join(part[:1].upper() + part[1:] for part in parts if part) + + +def format_input_layout(shader_name, vertex_declarations): + entries = vertex_declarations.get(shader_name, []) + return ",\n".join("{%d, %s, %d}" % (location, usage, int(multi_usage)) for location, usage, multi_usage in entries) + + +def format_shader_entry(shader_name, shader_group, vertex_declarations): + vert_shader = "std::nullopt" + frag_shader = "std::nullopt" + comp_shader = "std::nullopt" + + if shader_group["vert"]: + vert_shader = shader_wrapper(shader_group["vert"]) + if shader_group["frag"]: + frag_shader = shader_wrapper(shader_group["frag"]) + if shader_group["comp"]: + comp_shader = shader_wrapper(shader_group["comp"]) + + return ( + ' {"%s", { \n' + ' %s,\n' + ' %s,\n' + ' %s,\n' + ' %s,\n' + ' { %s }\n' + ' } \n' + ' },' + % ( + display_name(shader_name), + vert_shader, + frag_shader, + comp_shader, + str(shader_name.startswith("model_")).lower(), + format_input_layout(shader_name, vertex_declarations), + ) + ) + + +def enumerate_files(source_dir, binary_dir, output_file): + vertex_declarations = collect_vertex_declarations(source_dir) + shader_groups = collect_shader_groups(binary_dir) + + entries = [] + for shader_name in sorted(shader_groups.keys()): + shader_group = shader_groups[shader_name] + + if (not shader_group["vert"] or not shader_group["frag"]) and not shader_group["comp"]: + print("Warning: Shader group '%s' is missing a shader." % shader_name) continue - vertShader = "std::nullopt" - fragShader = "std::nullopt" - compShader = "std::nullopt" - if shaders[0]: - vertShader = """Shader( { - #include \"%s\" - } - )""" % ( shaders[0], ) - if shaders[1]: - fragShader = """Shader( { - #include \"%s\" - } )""" % shaders[1] - if shaders[2]: - compShader = """Shader( { - #include \"%s\" - } )""" % shaders[2] - - - splitNames = name.replace("model_", "").split("_") - displayName = name - if name.startswith("model_"): - displayName = " ".join(s[0].upper() + s[1:] for s in splitNames) - - code += """ -{\"%s\", { - %s, - %s, - %s, - %s, - { %s } - } -},""" % (displayName, vertShader, fragShader, compShader, str(name.startswith("model_")).lower(), ",\n".join(["{%d, %s}" % (index, usage) for (index, usage) in vertexDeclarations.get(name, {}) ])) - - code = code[:-1] - with open(output, 'w') as f: - f.write(code) + entries.append(format_shader_entry(shader_name, shader_group, vertex_declarations)) + code = "\n".join(entries) + + with open(output_file, "w") as f: + f.write(code) if __name__ == '__main__': if len(sys.argv) != 4: print("Usage: shaderCacheCreator.py ") sys.exit(1) - enumerate_files(sys.argv[1], sys.argv[2], sys.argv[3]) + enumerate_files(sys.argv[1], sys.argv[2], sys.argv[3]) \ No newline at end of file diff --git a/vendor/github.com/FortAwesome/Font-Awesome b/vendor/github.com/FortAwesome/Font-Awesome new file mode 160000 index 0000000..af62053 --- /dev/null +++ b/vendor/github.com/FortAwesome/Font-Awesome @@ -0,0 +1 @@ +Subproject commit af620534bfc3c2d4cbefcfeec29603bbe7809e64