From e49dff8ee6a6b1a2c27c9582426a389b720aad54 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sat, 31 Oct 2026 22:12:19 +0300 Subject: [PATCH 01/20] - imgui_backend: load ImGui style from resources/imgui/style.txt using KeyValues descriptors - keyvalues: clarify that ';' is required to end a key without a section --- docs/keyvalues.md | 5 +- shared/imgui_backend/imgui_host.cpp | 207 +++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 6 deletions(-) diff --git a/docs/keyvalues.md b/docs/keyvalues.md index 97377c4c0..79ec0a0b1 100644 --- a/docs/keyvalues.md +++ b/docs/keyvalues.md @@ -48,8 +48,9 @@ SourceControl p4 // key with a value and a nested section } ``` -- A key is followed by values separated by whitespace; the entry ends with `;` or with a `{ ... }` - section. +- A key is followed by values separated by whitespace. A key without a section must end with `;` + (see the examples above): a newline does not terminate an entry, so without the `;` the following + tokens are read as more values of the same key. A `{ ... }` section also ends the value list. - Values may be unquoted (no spaces or special characters) or quoted; quoted strings support escapes such as `\n`, `\t`, `\"`. - `//` and `/* ... */` comments. diff --git a/shared/imgui_backend/imgui_host.cpp b/shared/imgui_backend/imgui_host.cpp index 8d78d2abc..533f940a3 100644 --- a/shared/imgui_backend/imgui_host.cpp +++ b/shared/imgui_backend/imgui_host.cpp @@ -5,9 +5,12 @@ #include "core/ConCommand.h" #ifdef IMGUI_ENABLED + #include #include +#include "utils/KeyValues.h" + #include "imgui_impl_matsystem.h" #include "imgui_impl_sys.h" #include "imgui_internal.h" @@ -19,6 +22,204 @@ static CEqImGuiHost s_imGuiHost; CEqImGuiHost* g_imGuiHost = &s_imGuiHost; #ifdef IMGUI_ENABLED + +static ImVec4 ParseImGuiColorValue(const KVSection& key) +{ + const char* valueStr = key.ValueAt(0).GetString(); + if (key.ValueCount() == 1 && valueStr[0] == '#') + { + MColor hexColor = hexToColor4(valueStr + 1); + return ImVec4(hexColor.r, hexColor.g, hexColor.b, hexColor.a); + } + + if (key.ValueCount() < 3) + MsgWarning("ImGui style: '%s' needs RGB or RGBA values\n", key.GetName()); + + ImVec4 color(0.0f, 0.0f, 0.0f, 1.0f); + key.GetValues(color.x, color.y, color.z, color.w); + return color; +} + +// Descriptor value that writes the parsed entry directly into an ImGuiStyle member +template +struct ImGuiStyleValue +{ + T* ptr{ nullptr }; + void operator=(const T& value) const { *ptr = value; } +}; + +template +struct KVPairValuesGetter> +{ + using CompGetter = KVPairValuesGetter; + static float Get(const KVSection& section, int index) { return CompGetter::Get(section, index); } + static const int vcount = 1; +}; + +template<> struct KVPairValuesGetter> +{ + using CompGetter = KVPairValuesGetter; + static ImVec2 Get(const KVSection& section, int index) + { + return ImVec2(CompGetter::Get(section, index), CompGetter::Get(section, index + 1)); + } + static const int vcount = 2; +}; + +template<> struct KVPairValuesGetter> +{ + static ImVec4 Get(const KVSection& section, int index) { return ParseImGuiColorValue(section); } + static const int vcount = 1; +}; + +#define IMGUI_STYLE_COLOR_LIST(X) \ + X(Text) \ + X(TextDisabled) \ + X(WindowBg) \ + X(ChildBg) \ + X(PopupBg) \ + X(Border) \ + X(BorderShadow) \ + X(FrameBg) \ + X(FrameBgHovered) \ + X(FrameBgActive) \ + X(TitleBg) \ + X(TitleBgActive) \ + X(TitleBgCollapsed) \ + X(MenuBarBg) \ + X(ScrollbarBg) \ + X(ScrollbarGrab) \ + X(ScrollbarGrabHovered) \ + X(ScrollbarGrabActive) \ + X(CheckMark) \ + X(SliderGrab) \ + X(SliderGrabActive) \ + X(Button) \ + X(ButtonHovered) \ + X(ButtonActive) \ + X(Header) \ + X(HeaderHovered) \ + X(HeaderActive) \ + X(Separator) \ + X(SeparatorHovered) \ + X(SeparatorActive) \ + X(ResizeGrip) \ + X(ResizeGripHovered) \ + X(ResizeGripActive) \ + X(Tab) \ + X(TabHovered) \ + X(TabActive) \ + X(TabUnfocused) \ + X(TabUnfocusedActive) \ + X(PlotLines) \ + X(PlotLinesHovered) \ + X(PlotHistogram) \ + X(PlotHistogramHovered) \ + X(TableHeaderBg) \ + X(TableBorderStrong) \ + X(TableBorderLight) \ + X(TableRowBg) \ + X(TableRowBgAlt) \ + X(TextSelectedBg) \ + X(DragDropTarget) \ + X(NavHighlight) \ + X(NavWindowingHighlight) \ + X(NavWindowingDimBg) \ + X(ModalWindowDimBg) + +#define IMGUI_STYLE_FIELD_LIST(X) \ + X(Alpha, float) \ + X(DisabledAlpha, float) \ + X(WindowPadding, ImVec2) \ + X(WindowRounding, float) \ + X(WindowBorderSize, float) \ + X(WindowMinSize, ImVec2) \ + X(WindowTitleAlign, ImVec2) \ + X(WindowMenuButtonPosition, int) \ + X(ChildRounding, float) \ + X(ChildBorderSize, float) \ + X(PopupRounding, float) \ + X(PopupBorderSize, float) \ + X(FramePadding, ImVec2) \ + X(FrameRounding, float) \ + X(FrameBorderSize, float) \ + X(ItemSpacing, ImVec2) \ + X(ItemInnerSpacing, ImVec2) \ + X(CellPadding, ImVec2) \ + X(TouchExtraPadding, ImVec2) \ + X(IndentSpacing, float) \ + X(ColumnsMinSpacing, float) \ + X(ScrollbarSize, float) \ + X(ScrollbarRounding, float) \ + X(GrabMinSize, float) \ + X(GrabRounding, float) \ + X(LogSliderDeadzone, float) \ + X(TabRounding, float) \ + X(TabBorderSize, float) \ + X(TabMinWidthForCloseButton, float) \ + X(ColorButtonPosition, int) \ + X(ButtonTextAlign, ImVec2) \ + X(SelectableTextAlign, ImVec2) \ + X(DisplayWindowPadding, ImVec2) \ + X(DisplaySafeAreaPadding, ImVec2) \ + X(MouseCursorScale, float) \ + X(AntiAliasedLines, bool) \ + X(AntiAliasedLinesUseTex, bool) \ + X(AntiAliasedFill, bool) \ + X(CurveTessellationTol, float) \ + X(CircleTessellationMaxError, float) + +#define IMGUI_STYLE_COLOR_DECLARE(name) ImGuiStyleValue name{ &ImGui::GetStyle().Colors[ImGuiCol_##name] }; +#define IMGUI_STYLE_COLOR_DESC(name) KV_DESC_FIELD(name) +#define IMGUI_STYLE_FIELD_DECLARE(name, type) ImGuiStyleValue name{ &ImGui::GetStyle().name }; +#define IMGUI_STYLE_FIELD_DESC(name, type) KV_DESC_FIELD(name) + +#define IMGUI_STYLE_COLOR_COUNT(name) + 1 +static_assert(IMGUI_STYLE_COLOR_LIST(IMGUI_STYLE_COLOR_COUNT) == ImGuiCol_COUNT, "ImGui color list is out of date"); +#undef IMGUI_STYLE_COLOR_COUNT + +struct ImGuiStyleColorsDesc +{ + DEFINE_KEYVALUES_DESC(); + + IMGUI_STYLE_COLOR_LIST(IMGUI_STYLE_COLOR_DECLARE) +}; + +BEGIN_KEYVALUES_DESC(ImGuiStyleColorsDesc) + IMGUI_STYLE_COLOR_LIST(IMGUI_STYLE_COLOR_DESC) +END_KEYVALUES_DESC + +struct ImGuiStyleDesc +{ + DEFINE_KEYVALUES_DESC(); + + ImGuiStyleColorsDesc Colors; + + IMGUI_STYLE_FIELD_LIST(IMGUI_STYLE_FIELD_DECLARE) +}; + +BEGIN_KEYVALUES_DESC(ImGuiStyleDesc) + KV_DESC_EMBEDDED(Colors) + IMGUI_STYLE_FIELD_LIST(IMGUI_STYLE_FIELD_DESC) +END_KEYVALUES_DESC + +#undef IMGUI_STYLE_COLOR_DECLARE +#undef IMGUI_STYLE_COLOR_DESC +#undef IMGUI_STYLE_FIELD_DECLARE +#undef IMGUI_STYLE_FIELD_DESC +#undef IMGUI_STYLE_COLOR_LIST +#undef IMGUI_STYLE_FIELD_LIST + +static void LoadImGuiStyle(const char* fileName) +{ + KVSection styleKvs; + if (!KV_LoadFromFile(fileName, SP_DATA | SP_MOD, styleKvs)) + return; + + ImGuiStyleDesc styleDesc; + KeyValues::ParseDesc(styleDesc, styleKvs); +} + static void ImGuiBeginMenuPath(const char* path, bool& selected) { char tmpName[128] = { 0 }; @@ -69,15 +270,11 @@ void CEqImGuiHost::Init(EQWNDHANDLE window) //io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls //io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls - // Setup Dear ImGui style - ImGui::StyleColorsDark(); - // Setup Platform/Renderer backends ImGui_ImplMatSystem_Init(); static const char* fontFileName = "resources/imgui/Inter-Regular.ttf"; IFileStreamPtr fontFile = g_fileSystem->Open(fontFileName, FS_OPEN_READ, SP_DATA); - if (fontFile) { const int length = fontFile->GetSize(); @@ -91,6 +288,8 @@ void CEqImGuiHost::Init(EQWNDHANDLE window) io.Fonts->AddFontFromMemoryTTF(buffer, length, 16.0f, &font_cfg, io.Fonts->GetGlyphRangesCyrillic()); } + LoadImGuiStyle("resources/imgui/style.txt"); + // TODO: ImGui networked console port (for Android) if(window) { From 40278eabfceab813bb0164bed2e49193a4e0349f Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sat, 31 Oct 2026 22:16:27 +0300 Subject: [PATCH 02/20] - sys: add vid_maximized ConVar for startup with maximized window (for editors that need it) --- shared/sys/sys_window.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/sys/sys_window.cpp b/shared/sys/sys_window.cpp index 3ca425a03..d40848628 100644 --- a/shared/sys/sys_window.cpp +++ b/shared/sys/sys_window.cpp @@ -21,6 +21,7 @@ DECLARE_CVAR(sys_sleep, "1", "Sleep time for every frame", CV_ARCHIVE); DECLARE_CVAR(vid_screen, "0", "Fullscreen mode screen ID", CV_ARCHIVE); DECLARE_CVAR(vid_mode, "1024x768", "Screen Resoulution. Resolution string format: WIDTHxHEIGHT", CV_ARCHIVE); DECLARE_CVAR(vid_fullscreen, "0", "Enable fullscreen mode on startup", CV_ARCHIVE); +DECLARE_CVAR(vid_maximized, "0", "Maximize window at startup", CV_ARCHIVE); #define DEFAULT_WINDOW_TITLE "Initializing..." @@ -58,6 +59,9 @@ EQWNDHANDLE sysHostCreateWindow() if (fullscreen) sdlFlags |= SDL_WINDOW_FULLSCREEN; + if(vid_maximized.GetBool()) + sdlFlags |= SDL_WINDOW_MAXIMIZED; + #ifdef PLAT_ANDROID adjustedPosX = adjustedPosY = SDL_WINDOWPOS_UNDEFINED; From 3981f808f01c197b22ab05fe8152c73ebbcbe2d0 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 15:40:15 +0500 Subject: [PATCH 03/20] - equi: fix null panel dereference in showpanel and hidepanel commands - equi: fix BringToTop with a panel not in the root panel - equi: initialize CUIManager members in declarations, fix uninitialized m_mouseOver - equi: CUIManager refactoring (private Factory, const methods) - equi: mark control event handlers as deprecated - sys: esl - fix clipChilds getter in EqUI bindings Co-Authored-By: Claude Opus 5.5 --- shared/equi/EqUI_Manager.cpp | 53 ++++++++++++-------------- shared/equi/EqUI_Manager.h | 54 +++++++++++++-------------- shared/equi/IEqUI_Control.cpp | 12 +++--- shared/equi/IEqUI_Control.h | 2 +- shared/sys/scripting/sys_esl_equi.cpp | 2 +- 5 files changed, 58 insertions(+), 65 deletions(-) diff --git a/shared/equi/EqUI_Manager.cpp b/shared/equi/EqUI_Manager.cpp index f0ec06199..5b796759f 100644 --- a/shared/equi/EqUI_Manager.cpp +++ b/shared/equi/EqUI_Manager.cpp @@ -23,7 +23,7 @@ DECLARE_CMD(ui_showpanel, nullptr, CV_CHEAT) if(CMD_ARGC == 0) return; - equi::Panel* pPanel = equi::Manager->FindPanel( CMD_ARGV(0).ToCString() ); + equi::Panel* pPanel = equi::Manager->FindPanel( CMD_ARGV(0) ); if( pPanel ) pPanel->Show(); @@ -36,7 +36,7 @@ DECLARE_CMD(ui_hidepanel, nullptr, CV_CHEAT) if(CMD_ARGC == 0) return; - equi::Panel* pPanel = equi::Manager->FindPanel( CMD_ARGV(0).ToCString() ); + equi::Panel* pPanel = equi::Manager->FindPanel( CMD_ARGV(0) ); if( pPanel ) pPanel->Hide(); @@ -54,10 +54,6 @@ DECLARE_CVAR(equi_debug, "0", nullptr, 0); namespace equi { -CUIManager::CUIManager() : m_keyboardFocus(nullptr), m_rootPanel(nullptr), m_defaultFont(nullptr), m_mousePos(0) -{ -} - CUIManager::~CUIManager() { Shutdown(); @@ -95,7 +91,7 @@ Panel* CUIManager::GetRootPanel() const } // the element loader -void CUIManager::RegisterFactory(const char* name, ControlFactoryFunc factory) +void CUIManager::RegisterFactory(const char* name, ControlFactoryFn factory) { for(int i = 0; i < m_controlFactory.numElem(); i++) { @@ -106,18 +102,18 @@ void CUIManager::RegisterFactory(const char* name, ControlFactoryFunc factory) } } - ControlFactory& fac = m_controlFactory.append(); + Factory& fac = m_controlFactory.append(); fac.factory = factory; fac.name = name; } // the element loader -IUIControl* CUIManager::CreateElement( const char* type ) +IUIControl* CUIManager::CreateElement( const char* type ) const { - for(int i = 0; i < m_controlFactory.numElem(); i++) + for(const Factory& fac : m_controlFactory) { - if(!CString::CompareCaseIns(m_controlFactory[i].name, type)) - return (*m_controlFactory[i].factory)(); + if(!CString::CompareCaseIns(fac.name, type)) + return (*fac.factory)(); } ASSERT_FAIL("Unknown UI element type '%s'", type); @@ -165,25 +161,24 @@ void CUIManager::DestroyPanel( Panel* panel ) Panel* CUIManager::FindPanel( const char* pszPanelName ) const { - for(int i = 0; i < m_panels.numElem(); i++) - { - if(strlen(m_panels[i]->GetName()) > 0) - { - if(!CString::CompareCaseIns(pszPanelName, m_panels[i]->GetName())) - return m_panels[i]; - } - } + const int idx = arrayFindIndexF(m_panels, [pszPanelName](Panel* panel){ + return panel->m_name.Length() > 0 && !panel->m_name.CompareCaseIns(pszPanelName); + }); - return nullptr; + if (idx == -1) + return nullptr; + + return m_panels[idx]; } -void CUIManager::BringToTop( equi::Panel* panel ) +void CUIManager::BringToTop( Panel* panel ) { auto it = m_rootPanel->m_childs.findFront(panel); - m_rootPanel->m_childs.moveToFront(it); + if(!it.atEnd()) + m_rootPanel->m_childs.moveToFront(it); } -equi::Panel* CUIManager::GetTopPanel() const +Panel* CUIManager::GetTopPanel() const { auto it = m_rootPanel->m_childs.begin(); if (it.atEnd()) @@ -191,7 +186,7 @@ equi::Panel* CUIManager::GetTopPanel() const return static_cast(*it); } -void CUIManager::DumpPanelsToConsole() +void CUIManager::DumpPanelsToConsole() const { MsgInfo("Available UI panels/windows:\n"); @@ -234,9 +229,9 @@ IUIControl* CUIManager::GetMouseOver() const bool CUIManager::IsWindowsVisible() const { - for(int i = 0; i < m_panels.numElem(); i++) + for(const Panel* panel : m_panels) { - if(m_panels[i]->IsVisible() && m_panels[i]->m_windowControls) + if(panel->IsVisible() && panel->m_windowControls) return true; } @@ -269,14 +264,14 @@ void CUIManager::Render(ITexture* colorTarget) m_rootPanel->SetRectangle( m_viewFrameRect ); m_rootPanel->ResetSizeDiffs(); - equi::ControlRenderContext ctrlRenderContext{ rendPassRecorder }; + ControlRenderContext ctrlRenderContext{ rendPassRecorder }; m_rootPanel->Render(1, ctrlRenderContext); rendPassRecorder->DbgPopGroup(); g_matSystem->QueueCommandBuffer(rendPassRecorder->End()); } -equi::Panel* CUIManager::GetPanelByElement(IUIControl* control) +Panel* CUIManager::GetPanelByElement(IUIControl* control) const { IUIControl* firstCtrl = control; while(firstCtrl && arrayFindIndex(m_panels, (Panel*)firstCtrl) == -1) diff --git a/shared/equi/EqUI_Manager.h b/shared/equi/EqUI_Manager.h index cf9167d2c..128b115e7 100644 --- a/shared/equi/EqUI_Manager.h +++ b/shared/equi/EqUI_Manager.h @@ -18,39 +18,33 @@ namespace equi { class Panel; -using ControlFactoryFunc = IUIControl* (*)(); - -struct ControlFactory -{ - const char* name; - ControlFactoryFunc factory; -}; +using ControlFactoryFn = IUIControl* (*)(); class CUIManager { public: - CUIManager(); ~CUIManager(); + CUIManager() = default; void Init(); void Shutdown(); - equi::Panel* GetRootPanel() const; + Panel* GetRootPanel() const; // the element loader - void RegisterFactory(const char* name, ControlFactoryFunc factory); + void RegisterFactory(const char* name, ControlFactoryFn factory); template T* Create(); - IUIControl* CreateElement( const char* pszTypeName ); + IUIControl* CreateElement( const char* pszTypeName ) const; - void AddPanel( equi::Panel* panel); - void DestroyPanel( equi::Panel* pPanel ); - equi::Panel* FindPanel( const char* pszPanelName ) const; + void AddPanel( Panel* panel); + void DestroyPanel( Panel* pPanel ); + Panel* FindPanel( const char* pszPanelName ) const; - void BringToTop( equi::Panel* panel ); - equi::Panel* GetTopPanel() const; + void BringToTop( Panel* panel ); + Panel* GetTopPanel() const; void SetViewFrame(const IAARectangle& rect); const IAARectangle& GetViewFrame() const; @@ -69,29 +63,31 @@ class CUIManager bool ProcessMouseEvents(float x, float y, int nMouseButtons, int flags); bool ProcessKeyboardEvents(int nKeyButtons, int flags); - void DumpPanelsToConsole(); + void DumpPanelsToConsole() const; IEqFont* GetDefaultFont() const {return m_defaultFont;} private: - equi::Panel* GetPanelByElement(IUIControl* control); - - equi::Panel* m_rootPanel; - - IUIControl* m_keyboardFocus; - IUIControl* m_mouseOver; + struct Factory + { + const char* name; + ControlFactoryFn factory; + }; - IVector2D m_mousePos; + Panel* GetPanelByElement(IUIControl* control) const; - Array m_panels{ PP_SL }; + Array m_controlFactory{ PP_SL }; + IEqFont* m_defaultFont{ nullptr }; - IAARectangle m_viewFrameRect; - IMaterial* m_material; + Panel* m_rootPanel{ nullptr }; + Array m_panels{ PP_SL }; - IEqFont* m_defaultFont; + IUIControl* m_keyboardFocus{ nullptr }; + IUIControl* m_mouseOver{ nullptr }; - Array m_controlFactory{ PP_SL }; + IAARectangle m_viewFrameRect{ 0, 0, 640, 480 }; + IVector2D m_mousePos{ 0 }; }; template diff --git a/shared/equi/IEqUI_Control.cpp b/shared/equi/IEqUI_Control.cpp index 29d22ce36..cc019115f 100644 --- a/shared/equi/IEqUI_Control.cpp +++ b/shared/equi/IEqUI_Control.cpp @@ -949,21 +949,23 @@ int IUIControl::CommandCb(IUIControl* control, const EvtHandler& event, void* us else if (!UICMD_ARGV(0).CompareCaseIns("engine")) { // execute console commands - g_consoleCommands->SetCommandBuffer(UICMD_ARGV(1).ToCString()); + g_consoleCommands->SetCommandBuffer(UICMD_ARGV(1)); g_consoleCommands->ExecuteCommandBuffer(); g_consoleCommands->ClearCommandBuffer(); } else if (!UICMD_ARGV(0).CompareCaseIns("showpanel")) { // show panel - equi::Panel* panel = equi::Manager->FindPanel(UICMD_ARGV(1).ToCString()); - panel->Show(); + equi::Panel* panel = equi::Manager->FindPanel(UICMD_ARGV(1)); + if(panel) + panel->Show(); } else if (!UICMD_ARGV(0).CompareCaseIns("hidepanel")) { // hide panel - equi::Panel* panel = equi::Manager->FindPanel(UICMD_ARGV(1).ToCString()); - panel->Hide(); + equi::Panel* panel = equi::Manager->FindPanel(UICMD_ARGV(1)); + if (panel) + panel->Hide(); } // TODO: findChild/hideChild etc diff --git a/shared/equi/IEqUI_Control.h b/shared/equi/IEqUI_Control.h index 9551045cd..bef169a4e 100644 --- a/shared/equi/IEqUI_Control.h +++ b/shared/equi/IEqUI_Control.h @@ -254,7 +254,7 @@ class IUIControl : public WeakRefObject IUIControl* m_parent{ nullptr }; List m_childs{ PP_SL }; // child panels - Array m_eventCallbacks{ PP_SL }; + Array m_eventCallbacks{ PP_SL }; // DEPRECATED, not used - might be removed or reworked FontCollection m_fontCollection{ PP_SL }; FontProps m_font; diff --git a/shared/sys/scripting/sys_esl_equi.cpp b/shared/sys/scripting/sys_esl_equi.cpp index 89904da4c..7c0d4209d 100644 --- a/shared/sys/scripting/sys_esl_equi.cpp +++ b/shared/sys/scripting/sys_esl_equi.cpp @@ -193,7 +193,7 @@ EQSCRIPT_TYPE_BEGIN(equi::IUIControl) EQSCRIPT_BIND_VAR_EX_GET_SET(label, GetLabel, SetLabel) EQSCRIPT_BIND_VAR_EX_GET_SET(visible, IsVisible, SetVisible) EQSCRIPT_BIND_VAR_EX_GET_SET(selfVisible, IsSelfVisible, SetSelfVisible) - EQSCRIPT_BIND_VAR_EX_GET_SET(clipChilds, SetClipsChilds, SetClipsChilds) + EQSCRIPT_BIND_VAR_EX_GET_SET(clipChilds, IsClipsChilds, SetClipsChilds) EQSCRIPT_BIND_VAR_EX_GET_SET(clipTransform, HasClipTransform, SetClipTransform) EQSCRIPT_BIND_VAR_EX_GET_SET(enabled, IsEnabled, Enable) EQSCRIPT_BIND_VAR_EX_GET_SET(size, GetSize, SetSize) From bc59cc075713b6b4bdd268d11afd9134e680347a Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 15:40:15 +0500 Subject: [PATCH 04/20] - docs: add EqUI overview (equi_overview.md) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 3 +- docs/equi_overview.md | 282 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 docs/equi_overview.md diff --git a/docs/architecture.md b/docs/architecture.md index d5dab5429..6feb59b89 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ Related documents: | [animating_egf_overview.md](animating_egf_overview.md) | EGF animation (`shared/animating`): sequences, activities, blending, pose controllers, events, IK | | [rendering_matsystem_shaders_overview.md](rendering_matsystem_shaders_overview.md) | Material shaders: `CBaseShader`, pipelines, bind groups, `.def` files, overrides, `BaseUnlit` with GRIM | | [rendering_grim_overview.md](rendering_grim_overview.md) | GRIM: GPU-driven instance rendering, its shaders, `samples/GPUDriven` | +| [equi_overview.md](equi_overview.md) | EqUI (`shared/equi`): game UI controls, layout `.res` files, scaling, events, Lua bindings | | [sys_application_overview.md](sys_application_overview.md) | Application framework (`shared/sys`): start-up, game loop, app states, console, configs | | [input_overview.md](input_overview.md) | Input: key codes, command bindings, vector actions, touch zones, controllers | | [physics_overview.md](physics_overview.md) | Physics (`shared/physics`, eqPhysics): world, collision objects, rigid bodies, ray tests, constraints | @@ -86,7 +87,7 @@ Public interfaces are declared in `public/` and implemented in `core/` or `share | `shared/animating/` | Skeletal animation, bone setup, activities and events | | `shared/audio/` | Audio system on OpenAL, sound emitters/scripts/mixer; `source/` holds WAV/OGG sources | | `shared/physics/` | EqPhysics engine on top of Bullet collision — see [physics_overview.md](physics_overview.md) | -| `shared/equi/` | EqUI interface library: manager, controls, scheme | +| `shared/equi/` | EqUI interface library: manager, controls, scheme — see [equi_overview.md](equi_overview.md) | | `shared/imgui_backend/` | ImGui host (`g_imGuiHost`), SDL platform backend and material-system renderer backend | | `shared/scripting/` | ESL — Lua binding library | | `shared/movie/` | WebM movie player: AV1 video (dav1d), Vorbis audio, YUV to RGBA conversion on GPU (`MovieYUV` shader) | diff --git a/docs/equi_overview.md b/docs/equi_overview.md new file mode 100644 index 000000000..cc2d75c22 --- /dev/null +++ b/docs/equi_overview.md @@ -0,0 +1,282 @@ +# EqUI overview — `shared/equi` + +EqUI is the engine's retained-mode game UI: menus, HUD, loading screens. Layouts are trees of +controls described in KeyValues `.res` files and changed at runtime from C++ or Lua. It is built +as `equiLib` and drawn with the material system and the font system. Debug and tool UI uses ImGui +instead (`g_imGuiHost`, see [sys_application_overview.md](sys_application_overview.md)). + +| File | Contents | +|---|---| +| `EqUI_Manager.h/.cpp` | `equi::Manager` (`CUIManager`) — root panel, panels, control factories, focus, rendering, input | +| `IEqUI_Control.h/.cpp` | `equi::IUIControl` — base control: KeyValues parsing, layout, fonts, children, events, rendering | +| `equi_defs.h` | Anchors (`UI_BORDER_*`), alignment (`UI_ALIGN_*`), scaling modes (`UI_SCALING_*`), event flags (`UI_EVENT_*`) | +| `EqUI_Panel.h/.cpp` | `Panel` (top-level panel or window), `Container`, `HudElement` | +| `EqUI_Label.h/.cpp` | `Label` — text | +| `EqUI_Button.h/.cpp` | `Button` — clickable button | +| `EqUI_Image.h/.cpp` | `Image` — material or atlas image | +| `EqUI_Mask.h/.cpp` | `Mask` — draws its children through a mask texture | +| `EqUI_ProgressBar.h/.cpp` | `ProgressBar` | +| `shared/sys/scripting/sys_esl_equi.cpp` | Lua bindings (`equi`, `equi_cast`, `fonts`) | + +## Manager and panels + +`CGameHost` initializes the manager after the font cache (`equi::Manager->Init()`), renders it +every frame and passes mouse and keyboard events to it (see +[sys_application_overview.md](sys_application_overview.md)). + +- **Root panel:** `GetRootPanel()` is an invisible panel (`equi_root`) that covers the view frame. + Every top-level panel is its child. +- **Panels:** `AddPanel(panel)` adds a top-level panel to the root. Panel names must be unique + (`FindPanel(name)` is case-insensitive). `DestroyPanel(panel)` removes and deletes it. + `BringToTop(panel)` / `GetTopPanel()` change and return the drawing order. +- **View frame:** the host sets it to the window or the state viewport size each frame + (`SetViewFrame`). `GetScreenSize()` returns its size. +- **Focus:** `SetFocus(control)` / `GetFocus()` — the keyboard focus. Clicking a control focuses + it; clicking the root panel clears the focus. `GetMouseOver()` is the control under the cursor. +- **Default font:** `GetDefaultFont()` — `"default"` at size 22, used when no control in the + parent chain has a font. + +```cpp +equi::Panel* panel = equi::Manager->Create(); // factory by class name +panel->SetName("PauseMenu"); +equi::Manager->AddPanel(panel); + +KVSection uiKvs; +if (KV_LoadFromFile("resources/ui_pausemenu.res", SP_MOD, uiKvs)) + panel->InitFromKeyValues(uiKvs); + +// later +equi::Manager->DestroyPanel(panel); +``` + +### Control factories + +Controls are created by class name (case-insensitive) through registered factories: + +| Name | Class | Purpose | +|---|---|---| +| `panel` | `Panel` | Top-level panel or window, draws a filled rectangle (`color`) | +| `container` | `Container` | Group without drawing; can include children from a file | +| `HudElement` | `HudElement` | Group without drawing, used in HUD layouts | +| `label` | `Label` | Text | +| `button` | `Button` | Button with a label, raises `click` | +| `image` | `Image` | Material or atlas image | +| `mask` | `Mask` | Masked children | +| `progressBar` | `ProgressBar` | Horizontal fill bar | + +`CreateElement(name)` returns a new control; `Create()` creates by `T::Classname()` and casts. +To add a control type, derive from `IUIControl` (or another control), add `EQUI_CLASS(Class, Base)` +in the class body, implement `DrawSelf`, define the factory with +`DECLARE_EQUI_CONTROL(name, Class)` in its `.cpp` and register it with +`EQUI_REGISTER_CONTROL(name)` after `equi::Manager->Init()`. On non-MSVC compilers, declare the +factory with `EQUI_CONTROL_DECLARATION(name)` and register with `EQUI_REGISTER_CONTROL2(name)`. + +Type checks use class names: `control->As()` / `equi::DynamicCast(control)` +return `nullptr` when the class name differs (an exact match, not inheritance). + +## Layout files + +A layout file has one root section (`panel` for panels), with nested `child ""` +sections: + +``` +panel +{ + selfVisible 0; + position 0 0; + size 640 480; // layout units: children are scaled from this size + + child file "resources/ui_partial/menu_fonts.res"; // include children and fonts + + child image "MenuTitle" + { + path "ui/ui_menu_title_gradient"; + position -50 100; + size 300 30; + scaling uniform; + color 1.0 0.57 0.0 1; + + child label "text" + { + align left vcenter; + position 100 3; + size 200 20; + scaling uniform; + label "#MENU_TITLE"; // '#' = localization token + } + } + + child button "Resume" + { + label "Resume"; + position 10 64; + size 210 40; + command engine "echo Resume clicked"; + } +} +``` + +- `InitFromKeyValues(sec, keepElements)` parses the control and creates its children. With + `keepElements = false` existing children are deleted first. With `true`, a child with the same + name is updated in place (or replaced if its class differs), which is how HUD layouts are + layered over a base layout. +- `Panel::InitFromKeyValues` uses the nested `panel` section if there is one, otherwise the given + section. +- `child file ""` loads another file (path as is, `SP_MOD`) and adds its fonts and children + to this control. `Container` also accepts `file ""`, loaded from `resources/`. + +### Common properties — `IUIControl::Parse` + +| Key | Meaning | +|---|---| +| `position x y`, `size w h` | Rectangle in layout units, relative to the parent and its alignment | +| `visible 0/1` | Visibility (`Show`, `Hide`); hidden controls are not drawn and not hit-tested | +| `selfVisible 0/1` | Draw the control itself; children are still drawn | +| `label "text"` | UTF-8 text, or `#TOKEN` for a localized string | +| `align ...` | Alignment inside the parent: `left`, `right`, `hcenter`, `top`, `bottom`, `vcenter`, `center` | +| `anchors ...` | Sides kept at the same distance when the parent is resized: `left`, `top`, `right`, `bottom`, `all` | +| `scaling mode` | How the parent's resize scales the control: `none`, `inherit`, `inherit_min`, `inherit_max`, `aspectw`, `aspecth` / `uniform`, `aspect_min`, `aspect_max` | +| `transform { rotate deg; translate x y; elementScale x y; }` | Extra transform around the control center (does not affect hit testing) | +| `clipChilds 0/1`, `clipTransform 0/1` | Clip children to this control's rectangle; apply the transform to the clip rectangle. Default is inherited from the parent | +| `command ` | Handler for the `command` event, see [Events](#events) | +| `font "Name" [size] [bold] [italic]` | Font of the control. A single name refers to an entry of `fonts` in this control or a parent | +| `fonts { Name "Font" size; ... }` | Named fonts for children (text properties can be set per entry) | +| `fontScale x y`, `textColor r g b a`, `textAlign ...`, `textCase upper/lower`, `textWeight`, `textMonospace`, `textShadowColor`, `textShadowOffset`, `textShadowWeight` | Text style | + +Control-specific keys: + +| Control | Keys | +|---|---| +| `panel` | `color`, `window 1` (window with title and close button from `resources/WindowControls.res`, hidden by default), `screenoverlay 1` (always covers the view frame) | +| `image` | `path "material"` with optional `uvLeftTop` / `uvRightBottom`, or `atlas "material" "entry"`; `color`, `flipX`, `flipY` | +| `mask` | `image "mask texture"`, `color` | +| `progressBar` | `value` (0..1), `color` | + +### Size and scaling + +Positions and sizes are in layout units. When the manager resizes the root panel to the view +frame, the difference between the current and the parsed size (`GetSizeDiff()`, +`GetSizeDiffPerc()`) is used by children: + +- **Scaling** multiplies position, size and font scale by the parent's size ratio + (`CalcScaling()`). `uniform` keeps the aspect ratio by using the vertical ratio, so a layout + made for 640×480 keeps its proportions on wide screens. +- **Anchors** move or stretch the control by the parent's size difference. +- **Alignment** places the control relative to the parent's client rectangle; with `right` / + `bottom`, `position` is an offset from that side. + +`GetClientRectangle()` returns the final rectangle in screen pixels. + +## Controls at runtime + +```cpp +equi::IUIControl* title = panel->Get("MenuTitle.text"); // path of child names +title->SetLabel("#MENU_PAUSE"); +title->SetTextColor(ColorRGBA(1, 1, 0, 1)); + +if (equi::ProgressBar* bar = panel->FindChildRecursive("Health")->As()) + bar->SetValue(0.75f); +``` + +- Children: `AddChild`, `RemoveChild(ctrl, destroy)`, `FindChild(name)` (direct children), + `FindChildRecursive(name)`, `Get("a.b.c")`, `ForEachChild(func)`, `ClearChilds(destroy)`, + `ClearAll` (children, fonts and event handlers). A control owns its children and deletes them. +- Children added later are drawn on top and tested first for mouse hits. +- Visibility and state: `Show`, `Hide`, `SetVisible`, `SetSelfVisible`, `Enable`. `IsVisible()` + and `IsEnabled()` include the parents. +- Geometry: `SetPosition`, `SetSize`, `SetRectangle`, `SetAnchors`, `SetAlignment`, `SetScaling`, + `SetTransform(translate, scale, rotate)`. +- Text: `SetLabel` (UTF-8 or `#TOKEN`), `SetLabelText` (wide string), `SetFontScale`, + `SetTextColor`, `SetTextAlignment`. Labels support font tags (`TEXT_STYLE_USE_TAGS`). +- `Image::SetMaterial`, `SetAtlasImage`, `SetColor`, `SetUVRegion` (in texture pixels); + `ProgressBar::SetValue`, `SetColor`; `Panel::SetColor`, `CenterOnScreen`. + +### Events + +Event handlers are deprecated: they are not used by the game and may be removed or reworked. Do +not build new UI logic on them. Event handlers are named callbacks stored in the control: + +- `AddEventHandler(name, callback)` returns an id (`StringId24(name)`); `RemoveEventHandler(id)`, + `RemoveEventHandlers(name)`. +- `RaiseEvent(name, userData)` / `RaiseEventUid(id, userData)` call the **first** handler with + that name and return its result (-1 if there is none). +- `Button` raises `click` when the left mouse button is released over it after being pressed on + it. +- The `command` key in a layout adds a `command` handler with these actions: + + | Command | Action | + |---|---| + | `command engine ""` | Executes a console command | + | `command showpanel ""` / `hidepanel ""` | Shows / hides a top-level panel | + | `command hideparent` | Hides the parent control | + + Nothing raises `command` at the moment: `Button` raises only `click`, and `RaiseEvent` is not + bound to Lua. A `command` key has an effect only if C++ code calls `RaiseEvent("command", ...)` + on the control. + +## Rendering + +`Render(colorTarget)` draws the root panel in one render pass on the given target (the backbuffer +or the state viewport). Each control: + +1. Pushes its transform (`transform` block, rotated and scaled around its center). +2. Sets the scissor rectangle (its client rectangle, clipped by parents with `clipChilds`). +3. Calls `DrawSelf(rect, recorder)` if `selfVisible` (the root panel never draws itself). +4. Renders children, earliest first. + +`Mask` renders its children to a render target (`_ui__masked`) and draws it with the +`UIOverlayMask` shader and the mask texture. It must be `selfVisible`. + +## Input + +The host passes events in this order: ImGui → console → EqUI → app state (see +[sys_application_overview.md](sys_application_overview.md)). + +- EqUI handles input only while a panel with `window 1` is visible (`IsWindowsVisible()`). The + host also shows the cursor while such a panel is visible. +- Mouse: the manager hit-tests from the root panel, sends `UI_EVENT_MOUSE_IN` / `MOUSE_OUT` when + the control under the cursor changes, brings the clicked panel to the top and passes the event + to the control under the cursor (`ProcessMouseEvents`). A window panel can be dragged with the + left button. +- Keyboard: events go to the focused control (`ProcessKeyboardEvents`). +- An event is consumed when the control returns `true` (the default for all controls). + +## Lua + +`shared/sys/scripting/sys_esl_equi.cpp` binds EqUI to the engine Lua state (see +[scripting_esl_overview.md](scripting_esl_overview.md)): + +- `equi` — the manager: `GetRootPanel`, `CreateElement`, `AddPanel`, `DestroyPanel`, `FindPanel`, + `BringToTop`, `GetTopPanel`, `GetViewFrame`, `GetScreenSize`, `SetFocus`, `GetFocus`, + `GetMouseOver`, `IsWindowsVisible`, `GetDefaultFont`, plus `equi.FormatString(fmt, ...)` and + `equi.IsValid(control)`. +- Controls: `IUIControl` methods and properties (`name`, `label`, `visible`, `position`, `size`, + `anchors`, `alignment`, `scaling`, `rectangle`, ...), `Panel`, `Container`, `Image`, + `ProgressBar`. `control:SetLabel(fmt, ...)` formats the localized `fmt` with the extra + arguments. +- `equi_cast.panel(ctrl)`, `equi_cast.image(ctrl)`, `equi_cast.progressBar(ctrl)`, + `equi_cast.container(ctrl)` — typed casts, `nil` if the class differs. +- `fonts.LoadFontDescriptionFile(file)`; `UI_BORDER_*`, `UI_ALIGN_*`, `UI_SCALING_*`, + `TEXT_STYLE_*`, `TEXT_ALIGN_*` constants. + +```lua +local kvs = KeyValues.new() +if kvs:LoadFile("resources/ui_filmdirector.res", SP_MOD) then + layout:ClearAll(true) + layout:InitFromKeyValues(kvs:GetRoot(), false) +end + +local bar = equi_cast.progressBar(layout:FindChildRecursive("Health")) +if bar then + bar.value = 0.5 +end +``` + +## Console commands and debugging + +| Command / ConVar | Purpose | +|---|---| +| `ui_listpanels` | List top-level panels and their visibility | +| `ui_showpanel `, `ui_hidepanel ` | Show / hide a panel | +| `equi_debug ` | Draw control rectangles and names at the given tree depth (`-1` for all) | +| `equi_debugMaskClear 1` | Clear mask render targets to red | From e92e321345d42435c8d5354970ff11954a9c2bc7 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 18:08:10 +0500 Subject: [PATCH 05/20] - equi: add layout editor (EqUI_Editor) with outliner, preview, KeyValues view and property inspector - equi: support child file includes and editor-only controls in the layout editor - equi: add CUIManager::LoadLayoutFile and SetLayoutFileLoader for 'child file' loading - equi: add CUIManager::GetControlClassNames - equi: draw controls by selfVisible only, root panel is created with selfVisible off - sys: esl - register EqUI editor debug menu, add eslSysEquiTerm - docs: describe EqUI layout editor in equi_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/equi_overview.md | 68 +- shared/equi/EqUI_Editor.cpp | 1717 +++++++++++++++++++++++++ shared/equi/EqUI_Editor.h | 21 + shared/equi/EqUI_EditorProps.cpp | 585 +++++++++ shared/equi/EqUI_EditorProps.h | 26 + shared/equi/EqUI_Manager.cpp | 17 + shared/equi/EqUI_Manager.h | 10 + shared/equi/IEqUI_Control.cpp | 4 +- shared/sys/scripting/sys_esl.cpp | 1 + shared/sys/scripting/sys_esl_equi.cpp | 15 + shared/sys/scripting/sys_esl_equi.h | 3 +- 11 files changed, 2461 insertions(+), 6 deletions(-) create mode 100644 shared/equi/EqUI_Editor.cpp create mode 100644 shared/equi/EqUI_Editor.h create mode 100644 shared/equi/EqUI_EditorProps.cpp create mode 100644 shared/equi/EqUI_EditorProps.h diff --git a/docs/equi_overview.md b/docs/equi_overview.md index cc2d75c22..c6ac58071 100644 --- a/docs/equi_overview.md +++ b/docs/equi_overview.md @@ -16,6 +16,8 @@ instead (`g_imGuiHost`, see [sys_application_overview.md](sys_application_overvi | `EqUI_Image.h/.cpp` | `Image` — material or atlas image | | `EqUI_Mask.h/.cpp` | `Mask` — draws its children through a mask texture | | `EqUI_ProgressBar.h/.cpp` | `ProgressBar` | +| `EqUI_Editor.h/.cpp` | Layout editor (ImGui): document, outliner, preview, KeyValues view | +| `EqUI_EditorProps.h/.cpp` | Layout editor property inspector | | `shared/sys/scripting/sys_esl_equi.cpp` | Lua bindings (`equi`, `equi_cast`, `fonts`) | ## Manager and panels @@ -122,8 +124,11 @@ panel layered over a base layout. - `Panel::InitFromKeyValues` uses the nested `panel` section if there is one, otherwise the given section. -- `child file ""` loads another file (path as is, `SP_MOD`) and adds its fonts and children - to this control. `Container` also accepts `file ""`, loaded from `resources/`. +- `child file ""` loads another file (path as is, `SP_MOD`) with + `CUIManager::LoadLayoutFile` and adds its fonts and children to this control (as with + `keepElements = true`). `SetLayoutFileLoader` replaces the file system for these loads (the + editor gives its unsaved copies). `Container` also accepts `file ""`, loaded from + `resources/` directly. ### Common properties — `IUIControl::Parse` @@ -221,7 +226,9 @@ or the state viewport). Each control: 1. Pushes its transform (`transform` block, rotated and scaled around its center). 2. Sets the scissor rectangle (its client rectangle, clipped by parents with `clipChilds`). -3. Calls `DrawSelf(rect, recorder)` if `selfVisible` (the root panel never draws itself). +3. Calls `DrawSelf(rect, recorder)` if `selfVisible`. The manager root panel is created with + `selfVisible` off; controls rendered without a parent (the game HUD layout) are drawn like any + other control. 4. Renders children, earliest first. `Mask` renders its children to a render target (`_ui__masked`) and draws it with the @@ -280,3 +287,58 @@ end | `ui_showpanel `, `ui_hidepanel ` | Show / hide a panel | | `equi_debug ` | Draw control rectangles and names at the given tree depth (`-1` for all) | | `equi_debugMaskClear 1` | Clear mask render targets to red | + +## Layout editor + +`EqUI_Editor.cpp` is an ImGui editor of layout files, built in all configurations except `Retail` +and `Profile` (`EQUI_EDITOR`). The host registers it as the `ENGINE/UI/EQUI EDITOR` debug menu +after `equi::Manager->Init()` and calls `equiEditorShutdown()` before `equi::Manager->Shutdown()`, +so it can be opened in any sysLib application, including the game, where game controls are +registered. `equiEditorOpenFile(path)` opens a file from code. + +- **Document:** the layout is edited as a KeyValues document loaded from `SP_MOD` (File → Open + lists `.res` files under `resources/`). The preview controls are recreated from the document + after every change. Controls are matched with their `child` sections by the path of names from + the panel, so controls with duplicate names among siblings cannot be told apart. +- **Included files:** `child file` files are loaded into the editor when first needed and are + edited like the layout: the section of a control is searched in the same order as + `InitChildItems` (the last section with the name wins), including nested includes. The preview + is built with `SetLayoutFileLoader`, so it shows unsaved changes of included files. Save writes + the layout and every changed included file; note that included files are often shared by + several layouts. The inspector lists `child file` entries of the control (edit path, remove, + add, show in the KeyValues tab). `Container` `file` includes are not supported. +- **Preview:** the panel is a child of the editor's own root panel, which is not added to the + manager: it is not drawn by the host and does not receive game input. The editor renders it to + its own target with the size chosen in the tab (window size or a fixed resolution) the same way + as `CUIManager::Render`. With Stretch panel (default) the panel is resized to the preview size, + as the game does with HUD layouts, so children are scaled from the layout size. With Root + element the layout is rendered without a parent and without its own background, as the game + renders the HUD layout; it is on by default for files without a `panel` section. A fixed + resolution is centered in the tab, has rulers in preview pixels and can be zoomed (10-400%, + Ctrl + mouse wheel over the preview); the target keeps the resolution, only its display is + scaled. Clicking a control + selects it; selection and bounds are drawn without control transforms. +- **KeyValues tab:** the layout or a loaded included file as text; edits are applied back with + Apply. +- **Outliner:** the control tree. Controls written in included files are blue with the file + name. Controls without a section (window controls) are grayed out and read-only. The context + menu adds children of any class registered in the manager (`GetControlClassNames`, engine and + game controls), adds `child file` entries picked from `.res` files, moves children (up, down, to the beginning or the end), + deletes them and toggles editor-only. The outliner lists children in the reverse order of their + sections (`AddChild` prepends): upper controls are drawn on top and are written later in the + file. Moving changes the order of `child` keys in the section that has the control, so a control + written in an included file is moved inside that file. A visible + control under the mouse in the outliner is highlighted in the preview. + The checkbox before a control hides it in the preview only; hidden controls are kept by path + in the editor, they are not written to the document and are reset when a file is opened. +- **Inspector:** known keys of the control class (see [Layout files](#layout-files)) with typed + editors; a checkbox writes or removes the key, flag options wrap to the next line when the + column is narrow. `font` has a list of local fonts: names from `fonts` sections of the + control, its parents and their included files. Other keys are edited as raw values written as + in `.res` files; nested sections (`transform`, `fonts`) are edited in the KeyValues tab. +- **Editor-only controls:** child sections with `editorOnly 1;` are shown in the preview (for + example sample content for a list) and removed from the file on save. This includes + `child file "" { editorOnly 1; }` (Add child file... has an Editor only option, the + inspector has a checkbox per included file): controls of such a file and their children are + shown as editor-only. `InitChildItems` ignores the keys of `child file` sections. +- There is no undo yet; File → Reload discards unsaved changes. diff --git a/shared/equi/EqUI_Editor.cpp b/shared/equi/EqUI_Editor.cpp new file mode 100644 index 000000000..a88d65e6e --- /dev/null +++ b/shared/equi/EqUI_Editor.cpp @@ -0,0 +1,1717 @@ +////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) Inspiration Byte +// 2009-2025 +////////////////////////////////////////////////////////////////////////////////// +// Description: EqUI editor +////////////////////////////////////////////////////////////////////////////////// + +#include "core/core_common.h" +#include "core/IFileSystem.h" +#include "utils/KeyValues.h" +#include "materialsystem1/IMaterialSystem.h" + +#include "EqUI_Manager.h" +#include "EqUI_Panel.h" +#include "EqUI_EditorProps.h" +#include "EqUI_Editor.h" + +#ifdef EQUI_EDITOR +#include + +namespace equi::Editor +{ + +static constexpr const char* s_resourcesDir = "resources"; +static constexpr const int s_maxIncludeDepth = 16; +static constexpr const int s_minZoomPercent = 10; +static constexpr const int s_maxZoomPercent = 400; + +struct PreviewSize +{ + const char* name; + IVector2D size; +}; + +static const PreviewSize s_previewSizes[] = { + { "Fit to window", IVector2D(0, 0) }, + { "640x480", IVector2D(640, 480) }, + { "800x600", IVector2D(800, 600) }, + { "1280x720", IVector2D(1280, 720) }, + { "1920x1080", IVector2D(1920, 1080) }, +}; + +static constexpr const ImU32 s_selectionColor = IM_COL32(255, 160, 0, 255); +static constexpr const ImU32 s_hoverColor = IM_COL32(255, 255, 255, 160); +static constexpr const ImU32 s_boundsColor = IM_COL32(0, 200, 255, 90); +static const ImVec4 s_editorOnlyTextColor(1.0f, 0.6f, 0.2f, 1.0f); +static const ImVec4 s_includeTextColor(0.55f, 0.75f, 1.0f, 1.0f); + +//------------------------------------------------------------------------------------------------- + +// layout file: the edited one or one included with 'child file' +struct LayoutDoc : RefCountedObject +{ + EqString filePath; + KVSection root; + bool modified{ false }; + bool valid{ true }; // include file that failed to load is kept to not load it again +}; +using LayoutDocPtr = CRefPtr; + +// in outliner order, it is reversed order of the sections (IUIControl::AddChild prepends children) +enum EMoveChild +{ + MOVE_CHILD_UP, + MOVE_CHILD_DOWN, + MOVE_CHILD_TO_BEGINNING, + MOVE_CHILD_TO_END, +}; + +// section of a control, its parent section and the file it is written in +struct SectionRef +{ + KVSection* sec{ nullptr }; + KVSection* parent{ nullptr }; + LayoutDoc* doc{ nullptr }; // not owned, documents are kept until a layout is opened + bool editorOnlyInclude{ false }; // control or its parent comes from an editor-only 'child file' +}; + +// Layout is edited as KeyValues documents, preview controls are recreated from them after every change. +// Controls are matched with their sections by the path of child names from the panel. +class CUIEditor +{ +public: + void Draw(bool& open); + bool LoadDocument(const char* filePath); + void Shutdown(); + +private: + void DestroyPreviewPanel(); + void NewDocument(); + void ResetDocumentState(); + bool SaveDocument(LayoutDoc& doc, const char* filePath); + void SaveIncludes(); + bool IsModified() const; + void OnDocumentChanged(LayoutDoc* doc); + void RebuildPreview(); + void UpdateKVText(); + + void ClearIncludes(); + LayoutDoc* GetIncludeDoc(const char* filePath); + LayoutDoc* GetKVDoc(); + + KVSection& GetPanelSection(); + void FindChildSection(KVSection& sec, LayoutDoc* doc, const char* name, SectionRef& result, int depth, bool editorOnlyInclude); + SectionRef FindSectionByPath(const char* path); + EqString GetControlPath(const IUIControl* control) const; + IUIControl* GetSelectedControl() const; + void Select(const char* path); + bool IsEditorHidden(const char* path) const; + void SetEditorHidden(const char* path, bool hidden); + + void AddChildSection(const SectionRef& parentRef, const EqString& parentPath, const char* className, bool editorOnly); + void RemoveChildSection(const EqString& path); + void MoveChildSection(const EqString& path, EMoveChild move); + void CollectLocalFonts(const EqString& path, Array& outNames); + void CollectSectionFonts(const SectionRef& ref, Array& outNames); + + void DrawMenuBar(); + void DrawOpenPopup(); + void DrawSaveAsPopup(); + void DrawAddFilePopup(); + bool DrawResFileList(const char* selectedPath, EqString& outActivated); + void RequestResFileList(); + void AddIncludeSection(const EqString& path, const char* filePath, bool editorOnly); + void DrawOutliner(); + void DrawOutlinerNode(IUIControl* control); + void DrawAddChildMenu(const SectionRef& ref, const EqString& path, bool editorOnly); + void DrawPreviewTab(); + void DrawPreviewOverlay(const ImVec2& origin, float zoom, IUIControl* hovered); + void DrawPreviewRulers(const ImVec2& origin, const IVector2D& size, float zoom, const ImVec2& rulerSize); + void RenderPreview(const IVector2D& size); + void DrawKeyValuesTab(); + void DrawInspector(); + void DrawIncludes(const SectionRef& ref); + + LayoutDocPtr m_mainDoc; + Array m_includeDocs{ PP_SL }; + + // preview controls are not added to the manager, they are rendered to own target + Panel* m_previewRoot{ nullptr }; + Panel* m_panel{ nullptr }; + ITexturePtr m_previewTarget; + int m_previewSizeIdx{ 0 }; + int m_zoomPercent{ 100 }; // fixed resolutions only + ColorRGBA m_previewClearColor{ 0.2f, 0.2f, 0.2f, 1.0f }; + bool m_rebuildPending{ true }; + bool m_showBounds{ false }; + bool m_stretchPanel{ true }; + bool m_rootElement{ false }; // layout is rendered without a parent, like the game HUD + + // empty path is the panel itself + EqString m_selection; + bool m_hasSelection{ false }; + + // outliner item under the mouse in the current frame, outliner is drawn before the preview + EqString m_outlinerHoverPath; + bool m_hasOutlinerHover{ false }; + + // controls hidden in the preview only, not written to the document + Array m_editorHiddenPaths{ PP_SL }; + + // KeyValues tab, -1 is the main document, otherwise include index + Array m_kvText{ PP_SL }; + int m_kvDocIdx{ -1 }; + bool m_kvTextDirty{ true }; + bool m_kvTextEdited{ false }; + bool m_selectKVTab{ false }; + + // file popups + Array m_resFiles{ PP_SL }; + char m_pathInput[256]{}; + char m_fileFilter[64]{}; + EqString m_addFileTargetPath; // control that gets 'child file' + bool m_addFileEditorOnly{ false }; + bool m_openPopupRequest{ false }; + bool m_saveAsPopupRequest{ false }; + bool m_addFilePopupRequest{ false }; +}; + +static CStaticAutoPtr s_uiEditor; + +//------------------------------------------------------------------------------------------------- + +static void ScanResFiles(const EqString& dirPath, Array& outFiles) +{ + CFileSystemFind fsFind(EqString::Format("%s/*", dirPath), SP_MOD); + while (fsFind.Next()) + { + const EqStringRef name = fsFind.GetPath(); + if (name[0] == '.') + continue; + + const EqString filePath = EqString::Format("%s/%s", dirPath, name); + if (fsFind.IsDirectory()) + { + ScanResFiles(filePath, outFiles); + continue; + } + + if (fnmPathExtractExt(name).CompareCaseIns("res")) + continue; + + // same files may come from several search paths + const int foundIdx = arrayFindIndexF(outFiles, [&](const EqString& file) { + return !file.CompareCaseIns(filePath); + }); + + if (foundIdx == -1) + outFiles.append(filePath); + } +} + +static void RemoveEditorOnlySections(KVSection& sec) +{ + for (int i = sec.KeyCount() - 1; i >= 0; --i) + { + KVSection& key = sec.KeyAt(i); + if (!CString::CompareCaseIns(key.GetName(), "child") && IsEditorOnly(key)) + sec.RemoveSection(&key); + else if (key.KeyCount() > 0) + RemoveEditorOnlySections(key); + } +} + +static bool IsIncludeSection(const KVSection& childSec) +{ + return childSec.ValueCount() > 1 && !CString::CompareCaseIns(childSec.ValueAt(0).GetString(), "file"); +} + +static const char* GetChildSectionName(const KVSection& childSec) +{ + return childSec.ValueCount() > 1 ? childSec.ValueAt(1).GetString() : ""; +} + +static void AddFontNames(const KVSection& sec, Array& outNames) +{ + const KVSection* fontsSec = sec.FindSection("fonts", KV_FLAG_SECTION); + if (!fontsSec) + return; + + for (const KVSection& fontSec : fontsSec->Keys()) + { + const int foundIdx = arrayFindIndexF(outNames, [&](const EqString& name) { + return !name.CompareCaseIns(fontSec.GetName()); + }); + + if (foundIdx == -1) + outNames.append(fontSec.GetName()); + } +} + +static void SetIntPair(KVSection& sec, const char* name, int x, int y) +{ + KVSection& key = sec.CreateSection(name); + key.AddValue(x); + key.AddValue(y); +} + +// path of child names without the last one +static EqString GetParentPath(const EqString& path) +{ + const char* separator = strrchr(path.ToCString(), '.'); + if (!separator) + return EqString(); + return EqString(path.ToCString(), static_cast(separator - path.ToCString())); +} + +static EqString JoinPath(const EqString& parentPath, const char* name) +{ + if (parentPath.Length() == 0) + return EqString(name); + return EqString::Format("%s.%s", parentPath, name); +} + +static int KVTextResizeCb(ImGuiInputTextCallbackData* data) +{ + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) + { + Array* text = static_cast*>(data->UserData); + text->setNum(data->BufTextLen + 1, false); + data->Buf = text->ptr(); + } + return 0; +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::DestroyPreviewPanel() +{ + // panel is owned by the preview root unless it is the root element + if (m_panel && !m_panel->GetParent()) + delete m_panel; + m_panel = nullptr; + + if (m_previewRoot) + m_previewRoot->ClearChilds(true); +} + +void CUIEditor::Shutdown() +{ + DestroyPreviewPanel(); + SAFE_DELETE(m_previewRoot); + ClearIncludes(); + m_mainDoc = nullptr; + m_previewTarget = nullptr; + m_rebuildPending = true; +} + +void CUIEditor::ResetDocumentState() +{ + ClearIncludes(); + + // layouts without the panel section are loaded into a root element (HUD), others are added as panels + m_rootElement = m_mainDoc->root.FindSection("panel", KV_FLAG_SECTION) == nullptr; + + m_mainDoc->modified = false; + m_rebuildPending = true; + m_kvDocIdx = -1; + m_kvTextDirty = true; + m_kvTextEdited = false; + m_editorHiddenPaths.clear(); + Select(""); +} + +void CUIEditor::NewDocument() +{ + m_mainDoc = CRefPtr_new(LayoutDoc); + + KVSection& panelSec = m_mainDoc->root.CreateSection("panel", "NewPanel"); + SetIntPair(panelSec, "position", 0, 0); + SetIntPair(panelSec, "size", 640, 480); + + ResetDocumentState(); +} + +bool CUIEditor::LoadDocument(const char* filePath) +{ + IFileStreamPtr file = g_fileSystem->Open(filePath, FS_OPEN_READ, SP_MOD); + LayoutDocPtr doc = CRefPtr_new(LayoutDoc); + if (!file || !KeyValues::Parse(file, doc->root)) + { + MsgError("EqUI editor: can't load '%s'\n", filePath); + return false; + } + + doc->filePath = filePath; + m_mainDoc = doc; + + ResetDocumentState(); + return true; +} + +bool CUIEditor::SaveDocument(LayoutDoc& doc, const char* filePath) +{ + KVSection document; + doc.root.CopyTo(document); + RemoveEditorOnlySections(document); + + IFileStreamPtr file = g_fileSystem->Open(filePath, FS_OPEN_WRITE, SP_MOD); + if (!file) + { + MsgError("EqUI editor: can't write '%s'\n", filePath); + return false; + } + + KeyValues::WriteText(file, document); + + doc.filePath = filePath; + doc.modified = false; + Msg("EqUI editor: saved '%s'\n", filePath); + return true; +} + +void CUIEditor::SaveIncludes() +{ + for (const LayoutDocPtr& doc : m_includeDocs) + { + if (doc->modified) + SaveDocument(*doc, EqString(doc->filePath)); + } +} + +bool CUIEditor::IsModified() const +{ + if (m_mainDoc->modified) + return true; + + for (const LayoutDocPtr& doc : m_includeDocs) + { + if (doc->modified) + return true; + } + return false; +} + +void CUIEditor::OnDocumentChanged(LayoutDoc* doc) +{ + if (doc) + doc->modified = true; + + m_rebuildPending = true; + m_kvTextDirty = true; +} + +void CUIEditor::RebuildPreview() +{ + m_rebuildPending = false; + + if (!m_previewRoot) + { + // stands for the manager root panel which covers the view frame + m_previewRoot = Manager->Create(); + m_previewRoot->SetName("equi_editor_root"); + m_previewRoot->SetSelfVisible(false); + } + + DestroyPreviewPanel(); + + m_panel = Manager->Create(); + if (!m_rootElement) + m_previewRoot->AddChild(m_panel); + + // included files come from the editor so their changes are shown before saving + Manager->SetLayoutFileLoader([this](const char* fileName, KVSection& outSec) { + const LayoutDoc* doc = GetIncludeDoc(fileName); + if (!doc) + return false; + + doc->root.CopyTo(outSec); + return true; + }); + + m_panel->InitFromKeyValues(m_mainDoc->root); + + Manager->SetLayoutFileLoader(nullptr); + + // window panels are hidden after parsing + m_panel->Show(); + + // root elements are containers in the game, they don't draw themselves + if (m_rootElement) + m_panel->SetSelfVisible(false); + + for (const EqString& path : m_editorHiddenPaths) + { + IUIControl* control = path.Length() ? m_panel->Get(path) : m_panel; + if (control) + control->Hide(); + } +} + +void CUIEditor::UpdateKVText() +{ + m_kvTextDirty = false; + + CMemoryStream stream(PP_SL); + stream.Open(FS_OPEN_WRITE, nullptr, 4096); + KeyValues::WriteText(&stream, GetKVDoc()->root); + + const int textLength = static_cast(stream.Tell()); + m_kvText.setNum(textLength + 1, false); + memcpy(m_kvText.ptr(), stream.GetBasePointer(), textLength); + m_kvText[textLength] = 0; +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::ClearIncludes() +{ + m_includeDocs.clear(); +} + +LayoutDoc* CUIEditor::GetIncludeDoc(const char* filePath) +{ + for (const LayoutDocPtr& doc : m_includeDocs) + { + if (!doc->filePath.CompareCaseIns(filePath)) + return doc->valid ? doc.Ptr() : nullptr; + } + + LayoutDocPtr doc = CRefPtr_new(LayoutDoc); + doc->filePath = filePath; + m_includeDocs.append(doc); + + // same as CUIManager::LoadLayoutFile + IFileStreamPtr file = g_fileSystem->Open(filePath, FS_OPEN_READ, SP_MOD); + if (!file || !KeyValues::Parse(file, doc->root)) + { + MsgWarning("EqUI editor: can't load included file '%s'\n", filePath); + doc->valid = false; + return nullptr; + } + + return doc.Ptr(); +} + +LayoutDoc* CUIEditor::GetKVDoc() +{ + if (m_includeDocs.inRange(m_kvDocIdx) && m_includeDocs[m_kvDocIdx]->valid) + return m_includeDocs[m_kvDocIdx].Ptr(); + return m_mainDoc.Ptr(); +} + +KVSection& CUIEditor::GetPanelSection() +{ + // same as Panel::InitFromKeyValues + KVSection* panelSec = m_mainDoc->root.FindSection("panel", KV_FLAG_SECTION); + return panelSec ? *panelSec : m_mainDoc->root; +} + +// same order as IUIControl::InitChildItems, later section with the same name overrides the control +void CUIEditor::FindChildSection(KVSection& sec, LayoutDoc* doc, const char* name, SectionRef& result, int depth, bool editorOnlyInclude) +{ + for (KVSection& childSec : sec.Keys("child")) + { + if (IsIncludeSection(childSec)) + { + LayoutDoc* includeDoc = depth < s_maxIncludeDepth ? GetIncludeDoc(GetChildSectionName(childSec)) : nullptr; + if (includeDoc) + FindChildSection(includeDoc->root, includeDoc, name, result, depth + 1, editorOnlyInclude || IsEditorOnly(childSec)); + continue; + } + + if (!CString::Compare(GetChildSectionName(childSec), name)) + result = SectionRef{ &childSec, &sec, doc, editorOnlyInclude }; + } +} + +SectionRef CUIEditor::FindSectionByPath(const char* path) +{ + SectionRef ref{ &GetPanelSection(), nullptr, m_mainDoc.Ptr() }; + + EqString name; + for (const char* c = path; ; ++c) + { + if (*c && *c != '.') + { + name.Append(*c); + continue; + } + + if (name.Length()) + { + SectionRef childRef; + FindChildSection(*ref.sec, ref.doc, name, childRef, 0, false); + if (!childRef.sec) + return SectionRef(); + + // children of controls from editor-only files are removed with them + childRef.editorOnlyInclude = childRef.editorOnlyInclude || ref.editorOnlyInclude; + ref = childRef; + name.Clear(); + } + + if (!*c) + break; + } + return ref; +} + +EqString CUIEditor::GetControlPath(const IUIControl* control) const +{ + EqString path; + for (; control && control != m_panel; control = control->GetParent()) + { + if (path.Length()) + path = EqString::Format("%s.%s", control->GetName(), path); + else + path = control->GetName(); + } + return path; +} + +IUIControl* CUIEditor::GetSelectedControl() const +{ + if (!m_hasSelection || !m_panel) + return nullptr; + + if (m_selection.Length() == 0) + return m_panel; + + return m_panel->Get(m_selection); +} + +void CUIEditor::Select(const char* path) +{ + m_selection = path; + m_hasSelection = true; +} + +bool CUIEditor::IsEditorHidden(const char* path) const +{ + return arrayFindIndex(m_editorHiddenPaths, EqString(path)) != -1; +} + +void CUIEditor::SetEditorHidden(const char* path, bool hidden) +{ + const int pathIdx = arrayFindIndex(m_editorHiddenPaths, EqString(path)); + if (hidden && pathIdx == -1) + m_editorHiddenPaths.append(path); + else if (!hidden && pathIdx != -1) + m_editorHiddenPaths.fastRemoveIndex(pathIdx); + + // rebuild restores visibility from the layout + m_rebuildPending = true; +} + +void CUIEditor::AddChildSection(const SectionRef& parentRef, const EqString& parentPath, const char* className, bool editorOnly) +{ + KVSection& parentSec = *parentRef.sec; + + EqString name; + for (int i = 1; ; ++i) + { + name = EqString::Format("%s%d", className, i); + + bool nameUsed = false; + for (const KVSection& key : parentSec.Keys("child")) + nameUsed = nameUsed || !CString::Compare(GetChildSectionName(key), name); + + if (!nameUsed) + break; + } + + KVSection& childSec = parentSec.CreateSection("child", className); + childSec.AddValue(name); + SetIntPair(childSec, "position", 10, 10); + SetIntPair(childSec, "size", 100, 30); + + if (!CString::CompareCaseIns(className, "label") || !CString::CompareCaseIns(className, "button")) + childSec.SetKey("label", name); + + if (editorOnly) + childSec.SetKey(EDITOR_ONLY_KEY, 1); + + Select(JoinPath(parentPath, name)); + OnDocumentChanged(parentRef.doc); +} + +void CUIEditor::RemoveChildSection(const EqString& path) +{ + const SectionRef ref = FindSectionByPath(path); + if (!ref.sec || !ref.parent) + return; + + ref.parent->RemoveSection(ref.sec); + + // selection inside of the removed control + const EqString childPrefix = EqString::Format("%s.", path); + if (m_hasSelection && (m_selection == path || !strncmp(m_selection, childPrefix, childPrefix.Length()))) + Select(GetParentPath(path)); + + OnDocumentChanged(ref.doc); +} + +void CUIEditor::MoveChildSection(const EqString& path, EMoveChild move) +{ + const SectionRef ref = FindSectionByPath(path); + if (!ref.sec || !ref.parent) + return; + + Array& keys = ref.parent->keys; + const int idx = arrayFindIndex(keys, ref.sec); + if (idx == -1) + return; + + // only child keys are passed, other keys stay in place + const auto findChildKey = [&](int from, int step) { + for (int i = from + step; i >= 0 && i < keys.numElem(); i += step) + { + if (!CString::CompareCaseIns(keys[i]->GetName(), "child")) + return i; + } + return -1; + }; + + switch (move) + { + case MOVE_CHILD_UP: + case MOVE_CHILD_DOWN: + { + const int otherIdx = findChildKey(idx, move == MOVE_CHILD_UP ? 1 : -1); + if (otherIdx == -1) + return; + + keys[idx] = keys[otherIdx]; + keys[otherIdx] = ref.sec; + break; + } + case MOVE_CHILD_TO_BEGINNING: + { + if (findChildKey(idx, 1) == -1) + return; + + keys.removeIndex(idx); + keys.append(ref.sec); + break; + } + case MOVE_CHILD_TO_END: + { + const int firstIdx = findChildKey(-1, 1); + if (firstIdx == idx) + return; + + keys.removeIndex(idx); + keys.insert(ref.sec, firstIdx); + break; + } + } + + OnDocumentChanged(ref.doc); +} + +void CUIEditor::CollectSectionFonts(const SectionRef& ref, Array& outNames) +{ + if (!ref.sec) + return; + + AddFontNames(*ref.sec, outNames); + + // included files add their fonts to the control that includes them + for (const KVSection& childSec : ref.sec->Keys("child")) + { + if (!IsIncludeSection(childSec)) + continue; + + if (const LayoutDoc* includeDoc = GetIncludeDoc(GetChildSectionName(childSec))) + AddFontNames(includeDoc->root, outNames); + } +} + +// named fonts are searched in the control and its parents +void CUIEditor::CollectLocalFonts(const EqString& path, Array& outNames) +{ + CollectSectionFonts(FindSectionByPath(""), outNames); + + for (int i = 1; i <= path.Length(); ++i) + { + if (i == path.Length() || path[i] == '.') + CollectSectionFonts(FindSectionByPath(EqString(path.ToCString(), i)), outNames); + } + + arraySort(outNames, [](const EqString& a, const EqString& b) { + return a.CompareCaseIns(b); + }); +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::Draw(bool& open) +{ + if (!open) + return; + + if (m_rebuildPending) + { + if (!m_mainDoc) + NewDocument(); + RebuildPreview(); + } + + if (m_kvTextDirty && !m_kvTextEdited) + UpdateKVText(); + + const char* fileName = m_mainDoc->filePath.Length() ? m_mainDoc->filePath.ToCString() : "untitled"; + const EqString title = EqString::Format("EqUI Editor - %s%s###equiEditor", fileName, IsModified() ? "*" : ""); + + ImGui::SetNextWindowSize(ImVec2(1280, 720), ImGuiCond_FirstUseEver); + if (!ImGui::Begin(title, &open, ImGuiWindowFlags_MenuBar)) + { + ImGui::End(); + return; + } + + DrawMenuBar(); + DrawOpenPopup(); + DrawSaveAsPopup(); + DrawAddFilePopup(); + + const float layoutHeight = ImGui::GetContentRegionAvail().y; + constexpr ImGuiTableFlags layoutFlags = ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV; + if (ImGui::BeginTable("##equiEditorLayout", 3, layoutFlags)) + { + ImGui::TableSetupColumn("Outliner", ImGuiTableColumnFlags_WidthFixed, 260.0f); + ImGui::TableSetupColumn("View", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Inspector", ImGuiTableColumnFlags_WidthFixed, 360.0f); + ImGui::TableNextRow(); + + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##outliner", ImVec2(0, layoutHeight))) + DrawOutliner(); + ImGui::EndChild(); + + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##view", ImVec2(0, layoutHeight))) + { + if (ImGui::BeginTabBar("##viewTabs")) + { + if (ImGui::BeginTabItem("Preview")) + { + DrawPreviewTab(); + ImGui::EndTabItem(); + } + + const ImGuiTabItemFlags kvTabFlags = m_selectKVTab ? ImGuiTabItemFlags_SetSelected : 0; + m_selectKVTab = false; + if (ImGui::BeginTabItem("KeyValues", nullptr, kvTabFlags)) + { + DrawKeyValuesTab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + } + ImGui::EndChild(); + + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##inspector", ImVec2(0, layoutHeight))) + DrawInspector(); + ImGui::EndChild(); + + ImGui::EndTable(); + } + + ImGui::End(); +} + +void CUIEditor::DrawMenuBar() +{ + if (!ImGui::BeginMenuBar()) + return; + + if (ImGui::BeginMenu("File")) + { + if (ImGui::MenuItem("New")) + NewDocument(); + + if (ImGui::MenuItem("Open...")) + m_openPopupRequest = true; + + if (ImGui::MenuItem("Reload", nullptr, false, m_mainDoc->filePath.Length() > 0)) + LoadDocument(EqString(m_mainDoc->filePath)); + + ImGui::Separator(); + + // included files are saved together with the layout + if (ImGui::MenuItem("Save", nullptr, false, IsModified())) + { + if (m_mainDoc->filePath.Length()) + { + if (m_mainDoc->modified) + SaveDocument(*m_mainDoc, EqString(m_mainDoc->filePath)); + SaveIncludes(); + } + else + { + m_saveAsPopupRequest = true; + } + } + + if (ImGui::MenuItem("Save As...")) + m_saveAsPopupRequest = true; + + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) + { + ImGui::MenuItem("Show control bounds", nullptr, &m_showBounds); + ImGui::ColorEdit4("Background", &m_previewClearColor.x, ImGuiColorEditFlags_NoInputs); + ImGui::EndMenu(); + } + + ImGui::EndMenuBar(); +} + +void CUIEditor::RequestResFileList() +{ + m_resFiles.clear(); + ScanResFiles(s_resourcesDir, m_resFiles); + arraySort(m_resFiles, [](const EqString& a, const EqString& b) { + return a.CompareCaseIns(b); + }); +} + +// filtered list of .res files with the path input, returns true when a file is double-clicked +bool CUIEditor::DrawResFileList(const char* selectedPath, EqString& outActivated) +{ + bool activated = false; + + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##filter", "Filter", m_fileFilter, sizeof(m_fileFilter)); + + const float bottomHeight = ImGui::GetFrameHeightWithSpacing() * 2.0f; + if (ImGui::BeginChild("##files", ImVec2(0, -bottomHeight), true)) + { + for (const EqString& file : m_resFiles) + { + if (m_fileFilter[0] && file.Find(m_fileFilter) == -1) + continue; + + if (ImGui::Selectable(file, !file.CompareCaseIns(selectedPath), ImGuiSelectableFlags_AllowDoubleClick)) + { + strncpy(m_pathInput, file.ToCString(), sizeof(m_pathInput) - 1); + if (ImGui::IsMouseDoubleClicked(0)) + { + outActivated = file; + activated = true; + } + } + } + } + ImGui::EndChild(); + + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputText("##path", m_pathInput, sizeof(m_pathInput)); + + return activated; +} + +void CUIEditor::DrawOpenPopup() +{ + if (m_openPopupRequest) + { + m_openPopupRequest = false; + RequestResFileList(); + + strncpy(m_pathInput, m_mainDoc->filePath.ToCString(), sizeof(m_pathInput) - 1); + ImGui::OpenPopup("Open layout"); + } + + ImGui::SetNextWindowSize(ImVec2(500, 500), ImGuiCond_Appearing); + if (!ImGui::BeginPopupModal("Open layout")) + return; + + EqString activatedFile; + if (DrawResFileList(m_pathInput, activatedFile) && LoadDocument(activatedFile)) + ImGui::CloseCurrentPopup(); + + if (ImGui::Button("Open") && LoadDocument(m_pathInput)) + ImGui::CloseCurrentPopup(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); +} + +void CUIEditor::DrawSaveAsPopup() +{ + if (m_saveAsPopupRequest) + { + m_saveAsPopupRequest = false; + strncpy(m_pathInput, m_mainDoc->filePath.Length() ? m_mainDoc->filePath.ToCString() : "resources/", sizeof(m_pathInput) - 1); + ImGui::OpenPopup("Save layout"); + } + + if (!ImGui::BeginPopupModal("Save layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + return; + + ImGui::TextUnformatted("File path (SP_MOD):"); + ImGui::SetNextItemWidth(400.0f); + ImGui::InputText("##path", m_pathInput, sizeof(m_pathInput)); + + if (ImGui::Button("Save") && SaveDocument(*m_mainDoc, m_pathInput)) + { + SaveIncludes(); + ImGui::CloseCurrentPopup(); + } + + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); +} + +void CUIEditor::DrawAddFilePopup() +{ + if (m_addFilePopupRequest) + { + m_addFilePopupRequest = false; + RequestResFileList(); + + m_pathInput[0] = 0; + ImGui::OpenPopup("Add child file"); + } + + ImGui::SetNextWindowSize(ImVec2(500, 500), ImGuiCond_Appearing); + if (!ImGui::BeginPopupModal("Add child file")) + return; + + ImGui::Text("Add to: %s", m_addFileTargetPath.Length() ? m_addFileTargetPath.ToCString() : "(panel)"); + ImGui::Checkbox("Editor only", &m_addFileEditorOnly); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Controls of the file are shown in the preview only, the entry is removed on save"); + + EqString activatedFile; + if (DrawResFileList(m_pathInput, activatedFile)) + { + AddIncludeSection(m_addFileTargetPath, activatedFile, m_addFileEditorOnly); + ImGui::CloseCurrentPopup(); + } + + ImGui::BeginDisabled(m_pathInput[0] == 0); + if (ImGui::Button("Add")) + { + AddIncludeSection(m_addFileTargetPath, m_pathInput, m_addFileEditorOnly); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); +} + +void CUIEditor::AddIncludeSection(const EqString& path, const char* filePath, bool editorOnly) +{ + const SectionRef ref = FindSectionByPath(path); + if (!ref.sec) + return; + + // file that includes itself would be loaded recursively by IUIControl::InitChildItems + if (!m_mainDoc->filePath.CompareCaseIns(filePath) || !ref.doc->filePath.CompareCaseIns(filePath)) + { + MsgError("EqUI editor: '%s' can't include itself\n", filePath); + return; + } + + KVSection& includeSec = ref.sec->CreateSection("child", "file"); + includeSec.AddValue(filePath); + + if (editorOnly) + includeSec.SetKey(EDITOR_ONLY_KEY, 1); + + OnDocumentChanged(ref.doc); +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::DrawOutliner() +{ + ImGui::TextUnformatted("Outliner"); + ImGui::Separator(); + + m_hasOutlinerHover = false; + + if (m_panel) + DrawOutlinerNode(m_panel); +} + +void CUIEditor::DrawOutlinerNode(IUIControl* control) +{ + const EqString path = GetControlPath(control); + const SectionRef ref = FindSectionByPath(path); + const bool isPanel = control == m_panel; + const bool sectionEditorOnly = ref.sec && IsEditorOnly(*ref.sec); + const bool editorOnly = sectionEditorOnly || ref.editorOnlyInclude; + const bool fromInclude = ref.doc && ref.doc != m_mainDoc.Ptr(); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; + if (isPanel) + flags |= ImGuiTreeNodeFlags_DefaultOpen; + if (control->GetFirstChild().atEnd()) + flags |= ImGuiTreeNodeFlags_Leaf; + if (m_hasSelection && m_selection == path) + flags |= ImGuiTreeNodeFlags_Selected; + + // controls without a section are created by the control itself, e.g. window controls + const bool hasTextColor = editorOnly || fromInclude || !ref.sec; + if (editorOnly) + ImGui::PushStyleColor(ImGuiCol_Text, s_editorOnlyTextColor); + else if (fromInclude) + ImGui::PushStyleColor(ImGuiCol_Text, s_includeTextColor); + else if (!ref.sec) + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + + bool editorVisible = !IsEditorHidden(path); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + if (ImGui::Checkbox(EqString::Format("##editorVisible%s", path), &editorVisible)) + SetEditorHidden(path, !editorVisible); + ImGui::PopStyleVar(); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Show in the preview, not saved to the layout"); + + ImGui::SameLine(); + + // ids are paths so the tree state is kept when the preview is rebuilt + const bool nodeOpen = ImGui::TreeNodeEx(EqString::Format("#%s", path), flags, "%s [%s]%s%s%s", + control->GetName(), control->GetClassname(), + editorOnly ? " (editor only)" : "", + fromInclude ? " - " : "", fromInclude ? fnmPathExtractName(ref.doc->filePath).ToCString() : ""); + + if (hasTextColor) + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) + { + m_outlinerHoverPath = path; + m_hasOutlinerHover = true; + } + + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) + Select(path); + + if (ref.sec && ImGui::BeginPopupContextItem()) + { + if (ImGui::BeginMenu("Add child")) + { + DrawAddChildMenu(ref, path, false); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Add editor-only child")) + { + DrawAddChildMenu(ref, path, true); + ImGui::EndMenu(); + } + + if (ImGui::MenuItem("Add child file...")) + { + m_addFileTargetPath = path; + m_addFilePopupRequest = true; + } + + if (!isPanel) + { + ImGui::Separator(); + + static const char* s_moveItems[] = { "Move up", "Move down", "Move to beginning", "Move to end" }; + for (int i = 0; i < static_cast(elementsOf(s_moveItems)); ++i) + { + if (ImGui::MenuItem(s_moveItems[i])) + MoveChildSection(path, static_cast(i)); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Upper controls are drawn on top and written later in the file"); + } + + ImGui::Separator(); + + bool editorOnlyValue = sectionEditorOnly; + if (ImGui::MenuItem("Editor only", nullptr, &editorOnlyValue)) + { + if (editorOnlyValue) + ref.sec->SetKey(EDITOR_ONLY_KEY, 1); + else + ref.sec->RemoveSectionByName(EDITOR_ONLY_KEY); + OnDocumentChanged(ref.doc); + } + + if (ImGui::MenuItem("Delete")) + RemoveChildSection(path); + } + ImGui::EndPopup(); + } + + if (!nodeOpen) + return; + + for (auto it = control->GetFirstChild(); !it.atEnd(); ++it) + DrawOutlinerNode(*it); + + ImGui::TreePop(); +} + +void CUIEditor::DrawAddChildMenu(const SectionRef& ref, const EqString& path, bool editorOnly) +{ + // engine and game controls registered in the manager + static Array classNames{ PP_SL }; + classNames.clear(); + + Manager->GetControlClassNames(classNames); + arraySort(classNames, [](const char* a, const char* b) { + return CString::CompareCaseIns(a, b); + }); + + for (EqStringRef className : classNames) + { + if (ImGui::MenuItem(className)) + AddChildSection(ref, path, className, editorOnly); + } +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::RenderPreview(const IVector2D& size) +{ + if (!m_previewTarget) + { + // filtered for zoom + const SamplerStateParams sampler(TEXFILTER_LINEAR, TEXADDRESS_CLAMP); + m_previewTarget = g_renderAPI->CreateRenderTarget( + Builder() + .Name("_equi_editor_preview") + .Size(size.x, size.y) + .Format(g_matSystem->GetCurrentBackbuffer()->GetFormat()) + .Sampler(sampler) + .End() + ); + } + else if (m_previewTarget->GetWidth() != size.x || m_previewTarget->GetHeight() != size.y) + { + g_renderAPI->ResizeRenderTarget(m_previewTarget, { size.x, size.y, 1 }); + } + + // screen overlay panels are resized by CUIManager::Render, HUD layouts are resized by the game + if (m_stretchPanel || KV_GetValueBool(GetPanelSection().FindSection("screenoverlay"), 0, false)) + { + m_panel->SetPosition(0); + m_panel->SetSize(size); + } + + // parsed size is the base size, so root does not scale its children like the manager root + if (m_previewRoot->GetSize() != size) + { + KVSection rootSec; + SetIntPair(rootSec, "position", 0, 0); + SetIntPair(rootSec, "size", size.x, size.y); + m_previewRoot->Parse(rootSec); + } + + // editor is drawn in the middle of the host frame + Matrix4x4 prevProj, prevView, prevWorld, prevWorld2; + g_matSystem->GetMatrix(MATRIXMODE_PROJECTION, prevProj); + g_matSystem->GetMatrix(MATRIXMODE_VIEW, prevView); + g_matSystem->GetMatrix(MATRIXMODE_WORLD, prevWorld); + g_matSystem->GetMatrix(MATRIXMODE_WORLD2, prevWorld2); + + g_matSystem->Setup2D(size.x, size.y); + + IGPURenderPassRecorderPtr rendPassRecorder = g_renderAPI->BeginRenderPass( + Builder() + .ColorTarget(m_previewTarget, true, m_previewClearColor) + .End() + ); + + ControlRenderContext ctrlRenderContext{ rendPassRecorder }; + if (m_rootElement) + m_panel->Render(1, ctrlRenderContext); + else + m_previewRoot->Render(1, ctrlRenderContext); + + g_matSystem->QueueCommandBuffer(rendPassRecorder->End()); + + g_matSystem->SetMatrix(MATRIXMODE_PROJECTION, prevProj); + g_matSystem->SetMatrix(MATRIXMODE_VIEW, prevView); + g_matSystem->SetMatrix(MATRIXMODE_WORLD, prevWorld); + g_matSystem->SetMatrix(MATRIXMODE_WORLD2, prevWorld2); +} + +void CUIEditor::DrawPreviewTab() +{ + ImGui::SetNextItemWidth(150.0f); + if (ImGui::BeginCombo("Size", s_previewSizes[m_previewSizeIdx].name)) + { + for (int i = 0; i < static_cast(elementsOf(s_previewSizes)); ++i) + { + if (ImGui::Selectable(s_previewSizes[i].name, i == m_previewSizeIdx)) + m_previewSizeIdx = i; + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + if (ImGui::Checkbox("Root element", &m_rootElement)) + m_rebuildPending = true; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Render the layout without a parent and without its own background, like the game HUD.\nOff: the layout is a panel added to the manager root panel."); + + ImGui::SameLine(); + ImGui::Checkbox("Stretch panel", &m_stretchPanel); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Resize the panel to the preview size, children are scaled from the layout size"); + + ImGui::SameLine(); + ImGui::Checkbox("Bounds", &m_showBounds); + + IVector2D size = s_previewSizes[m_previewSizeIdx].size; + const bool fitToWindow = size.x == 0; + + ImGui::SameLine(); + ImGui::BeginDisabled(fitToWindow); + ImGui::SetNextItemWidth(150.0f); + ImGui::SliderInt("Zoom", &m_zoomPercent, s_minZoomPercent, s_maxZoomPercent, "%d%%"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Ctrl + mouse wheel over the preview, double-click resets to 100%%"); + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + m_zoomPercent = 100; + ImGui::EndDisabled(); + + // mouse wheel with Ctrl zooms instead of scrolling + const ImGuiWindowFlags areaFlags = ImGuiWindowFlags_HorizontalScrollbar | (ImGui::GetIO().KeyCtrl ? ImGuiWindowFlags_NoScrollWithMouse : 0); + if (!ImGui::BeginChild("##previewArea", ImVec2(0, 0), false, areaFlags)) + { + ImGui::EndChild(); + return; + } + + const ImVec2 availSize = ImGui::GetContentRegionAvail(); + if (fitToWindow) + size = IVector2D(max(static_cast(availSize.x), 1), max(static_cast(availSize.y), 1)); + + const float zoom = fitToWindow ? 1.0f : m_zoomPercent / 100.0f; + const ImVec2 displaySize(floorf(size.x * zoom), floorf(size.y * zoom)); + + if (m_panel) + { + RenderPreview(size); + + ImVec2 origin = ImGui::GetCursorScreenPos(); + ImVec2 rulerSize(0.0f, 0.0f); + + // fixed resolution is centered in the area, rulers are on the top and left sides + if (!fitToWindow) + { + rulerSize = ImVec2(ImGui::CalcTextSize("0000").x + 6.0f, ImGui::GetFontSize() + 4.0f); + + const ImVec2 blockSize(rulerSize.x + displaySize.x, rulerSize.y + displaySize.y); + const ImVec2 offset(max((availSize.x - blockSize.x) * 0.5f, 0.0f), max((availSize.y - blockSize.y) * 0.5f, 0.0f)); + origin = ImVec2(floorf(origin.x + offset.x + rulerSize.x), floorf(origin.y + offset.y + rulerSize.y)); + ImGui::SetCursorScreenPos(origin); + } + + ImGui::Image(reinterpret_cast(m_previewTarget.Ptr()), displaySize); + const bool previewHovered = ImGui::IsItemHovered(); + const bool previewClicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + + const ImGuiIO& io = ImGui::GetIO(); + if (previewHovered && !fitToWindow && io.KeyCtrl && io.MouseWheel != 0.0f) + m_zoomPercent = clamp(m_zoomPercent + static_cast(io.MouseWheel) * 10, s_minZoomPercent, s_maxZoomPercent); + + IUIControl* hovered = nullptr; + if (previewHovered) + { + const ImVec2 mousePos = ImGui::GetMousePos(); + hovered = m_panel->HitTest(IVector2D(static_cast((mousePos.x - origin.x) / zoom), static_cast((mousePos.y - origin.y) / zoom))); + } + + if (hovered && previewClicked) + Select(GetControlPath(hovered)); + + if (hovered) + ImGui::SetTooltip("%s [%s]", hovered->GetName(), hovered->GetClassname()); + + // control under the mouse in the outliner is highlighted when visible + if (!hovered && m_hasOutlinerHover) + { + IUIControl* outlinerHovered = m_outlinerHoverPath.Length() ? m_panel->Get(m_outlinerHoverPath) : m_panel; + if (outlinerHovered && outlinerHovered->IsVisible()) + hovered = outlinerHovered; + } + + DrawPreviewOverlay(origin, zoom, hovered); + + if (!fitToWindow) + DrawPreviewRulers(origin, size, zoom, rulerSize); + } + + ImGui::EndChild(); +} + +void CUIEditor::DrawPreviewRulers(const ImVec2& origin, const IVector2D& size, float zoom, const ImVec2& rulerSize) +{ + const ImVec2 displaySize(floorf(size.x * zoom), floorf(size.y * zoom)); + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImU32 backColor = ImGui::GetColorU32(ImGuiCol_FrameBg); + const ImU32 tickColor = ImGui::GetColorU32(ImGuiCol_TextDisabled); + const ImU32 textColor = ImGui::GetColorU32(ImGuiCol_Text); + + drawList->AddRectFilled(ImVec2(origin.x - rulerSize.x, origin.y - rulerSize.y), ImVec2(origin.x + displaySize.x, origin.y), backColor); + drawList->AddRectFilled(ImVec2(origin.x - rulerSize.x, origin.y), ImVec2(origin.x, origin.y + displaySize.y), backColor); + + // steps in preview pixels are chosen to keep ticks and labels apart on screen + static const int s_rulerSteps[] = { 10, 50, 100, 200, 500, 1000 }; + const auto pickStep = [zoom](float minScreenDistance) { + for (const int step : s_rulerSteps) + { + if (step * zoom >= minScreenDistance) + return step; + } + return s_rulerSteps[elementsOf(s_rulerSteps) - 1]; + }; + + const int tickStep = pickStep(5.0f); + const int labelStep = max(pickStep(ImGui::CalcTextSize("0000").x + 8.0f), tickStep); + + const auto tickLength = [&](int pos, float rulerLength) { + if (pos % labelStep == 0) + return rulerLength; + return pos % (tickStep * 5) == 0 ? rulerLength * 0.5f : rulerLength * 0.25f; + }; + + for (int x = 0; x <= size.x; x += tickStep) + { + const float tickX = origin.x + floorf(x * zoom); + drawList->AddLine(ImVec2(tickX, origin.y), ImVec2(tickX, origin.y - tickLength(x, rulerSize.y)), tickColor); + if (x % labelStep == 0) + drawList->AddText(ImVec2(tickX + 2.0f, origin.y - rulerSize.y), textColor, EqString::Format("%d", x)); + } + + for (int y = 0; y <= size.y; y += tickStep) + { + const float tickY = origin.y + floorf(y * zoom); + drawList->AddLine(ImVec2(origin.x, tickY), ImVec2(origin.x - tickLength(y, rulerSize.x), tickY), tickColor); + if (y % labelStep == 0) + drawList->AddText(ImVec2(origin.x - rulerSize.x + 2.0f, tickY + 1.0f), textColor, EqString::Format("%d", y)); + } + + // mouse position marks + const ImVec2 mousePos = ImGui::GetMousePos(); + if (mousePos.x >= origin.x && mousePos.x <= origin.x + displaySize.x) + drawList->AddLine(ImVec2(mousePos.x, origin.y), ImVec2(mousePos.x, origin.y - rulerSize.y), s_selectionColor); + if (mousePos.y >= origin.y && mousePos.y <= origin.y + displaySize.y) + drawList->AddLine(ImVec2(origin.x, mousePos.y), ImVec2(origin.x - rulerSize.x, mousePos.y), s_selectionColor); +} + +void CUIEditor::DrawPreviewOverlay(const ImVec2& origin, float zoom, IUIControl* hovered) +{ + ImDrawList* drawList = ImGui::GetWindowDrawList(); + + // control transforms are not applied to the rectangles + const auto drawControlRect = [&](const IUIControl* control, ImU32 color, float thickness) { + const IAARectangle rect = control->GetClientRectangle(); + drawList->AddRect( + ImVec2(origin.x + floorf(rect.leftTop.x * zoom), origin.y + floorf(rect.leftTop.y * zoom)), + ImVec2(origin.x + floorf(rect.rightBottom.x * zoom), origin.y + floorf(rect.rightBottom.y * zoom)), + color, 0.0f, 0, thickness); + }; + + if (m_showBounds) + { + const EqFunction drawBounds = [&](IUIControl* control) { + if (!control->IsVisible()) + return true; + drawControlRect(control, s_boundsColor, 1.0f); + control->ForEachChild(drawBounds); + return true; + }; + m_panel->ForEachChild(drawBounds); + } + + if (hovered) + drawControlRect(hovered, s_hoverColor, 1.0f); + + if (const IUIControl* selected = GetSelectedControl()) + drawControlRect(selected, s_selectionColor, 2.0f); +} + +void CUIEditor::DrawKeyValuesTab() +{ + LayoutDoc* kvDoc = GetKVDoc(); + + // switching the file discards text changes + ImGui::SetNextItemWidth(250.0f); + if (ImGui::BeginCombo("File", kvDoc == m_mainDoc.Ptr() ? "(layout)" : kvDoc->filePath.ToCString())) + { + if (ImGui::Selectable("(layout)", kvDoc == m_mainDoc.Ptr())) + m_kvDocIdx = -1; + + for (int i = 0; i < m_includeDocs.numElem(); ++i) + { + if (!m_includeDocs[i]->valid) + continue; + + if (ImGui::Selectable(m_includeDocs[i]->filePath, kvDoc == m_includeDocs[i].Ptr())) + m_kvDocIdx = i; + } + ImGui::EndCombo(); + + if (GetKVDoc() != kvDoc) + { + m_kvTextEdited = false; + m_kvTextDirty = true; + UpdateKVText(); + kvDoc = GetKVDoc(); + } + } + + ImGui::SameLine(); + ImGui::BeginDisabled(!m_kvTextEdited); + if (ImGui::Button("Apply")) + { + KVSection document; + if (KeyValues::ParseText(m_kvText.ptr(), CString::Length(m_kvText.ptr()), document, "EqUI editor")) + { + kvDoc->root.Clear(); + document.CopyTo(kvDoc->root); + m_kvTextEdited = false; + OnDocumentChanged(kvDoc); + } + else + { + MsgError("EqUI editor: can't parse layout text\n"); + } + } + + ImGui::SameLine(); + if (ImGui::Button("Revert")) + { + m_kvTextEdited = false; + m_kvTextDirty = true; + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (m_kvTextEdited && m_kvTextDirty) + ImGui::TextColored(s_editorOnlyTextColor, "Layout was changed in the editor, Apply overwrites the changes"); + else + ImGui::TextDisabled("Sections with '%s' are removed on save", EDITOR_ONLY_KEY); + + if (m_kvText.numElem() == 0) + m_kvText.append(0); + + constexpr ImGuiInputTextFlags textFlags = ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize; + if (ImGui::InputTextMultiline("##kvText", m_kvText.ptr(), m_kvText.numElem(), ImVec2(-FLT_MIN, -FLT_MIN), textFlags, KVTextResizeCb, &m_kvText)) + m_kvTextEdited = true; +} + +//------------------------------------------------------------------------------------------------- + +void CUIEditor::DrawInspector() +{ + ImGui::TextUnformatted("Inspector"); + ImGui::Separator(); + + IUIControl* control = GetSelectedControl(); + if (!control) + { + ImGui::TextDisabled("Select a control in the outliner or the preview"); + return; + } + + const SectionRef ref = FindSectionByPath(m_selection); + KVSection* sec = ref.sec; + const bool isPanel = control == m_panel; + + ImGui::Text("Class: %s", control->GetClassname()); + + const IAARectangle rect = control->GetClientRectangle(); + ImGui::TextDisabled("Screen rect: %d %d - %d %d", rect.leftTop.x, rect.leftTop.y, rect.rightBottom.x, rect.rightBottom.y); + + if (!sec) + { + ImGui::Text("Name: %s", control->GetName()); + ImGui::TextWrapped("Control is created by its parent and has no layout section."); + return; + } + + if (ref.doc != m_mainDoc.Ptr()) + { + ImGui::TextColored(s_includeTextColor, "File: %s%s", ref.doc->filePath, ref.doc->modified ? "*" : ""); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Control is written in the included file, the file is saved with the layout"); + } + + if (ref.editorOnlyInclude) + ImGui::TextColored(s_editorOnlyTextColor, "Included as editor-only, removed from the saved layout"); + + if (!isPanel) + { + char nameBuffer[128]; + strncpy(nameBuffer, control->GetName(), sizeof(nameBuffer) - 1); + nameBuffer[sizeof(nameBuffer) - 1] = 0; + + if (ImGui::InputText("Name", nameBuffer, sizeof(nameBuffer), ImGuiInputTextFlags_EnterReturnsTrue) && nameBuffer[0]) + { + sec->SetValue(nameBuffer, 1); + + Select(JoinPath(GetParentPath(m_selection), nameBuffer)); + OnDocumentChanged(ref.doc); + } + + bool editorOnly = IsEditorOnly(*sec); + if (ImGui::Checkbox("Editor only", &editorOnly)) + { + if (editorOnly) + sec->SetKey(EDITOR_ONLY_KEY, 1); + else + sec->RemoveSectionByName(EDITOR_ONLY_KEY); + OnDocumentChanged(ref.doc); + } + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Shown in the preview only, removed from the saved layout"); + } + + ImGui::Separator(); + + // class is the first value of child sections + const char* className = isPanel ? "panel" : sec->ValueAt(0).GetString(); + + static Array localFonts{ PP_SL }; + localFonts.clear(); + + CollectLocalFonts(m_selection, localFonts); + + const PropertiesContext propsCtx{ localFonts }; + if (DrawControlProperties(*sec, className, propsCtx)) + OnDocumentChanged(ref.doc); + + DrawIncludes(ref); +} + +// 'child file' entries of the control section +void CUIEditor::DrawIncludes(const SectionRef& ref) +{ + ImGui::Separator(); + ImGui::TextUnformatted("Included files"); + + KVSection& sec = *ref.sec; + int removeKeyIdx = -1; + + for (int i = 0; i < sec.KeyCount(); ++i) + { + KVSection& childSec = sec.KeyAt(i); + if (CString::CompareCaseIns(childSec.GetName(), "child") || !IsIncludeSection(childSec)) + continue; + + ImGui::PushID(i); + if (ImGui::SmallButton("x")) + removeKeyIdx = i; + + ImGui::SameLine(); + const char* includePath = GetChildSectionName(childSec); + const int includeIdx = arrayFindIndexF(m_includeDocs, [&](const LayoutDocPtr& doc) { + return !doc->filePath.CompareCaseIns(includePath); + }); + + bool includeEditorOnly = IsEditorOnly(childSec); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + if (ImGui::Checkbox("##editorOnly", &includeEditorOnly)) + { + if (includeEditorOnly) + childSec.SetKey(EDITOR_ONLY_KEY, 1); + else + childSec.RemoveSectionByName(EDITOR_ONLY_KEY); + OnDocumentChanged(ref.doc); + } + ImGui::PopStyleVar(); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Editor only: shown in the preview, removed on save"); + + ImGui::SameLine(); + ImGui::BeginDisabled(includeIdx == -1 || !m_includeDocs[includeIdx]->valid); + if (ImGui::SmallButton("KV")) + { + m_kvDocIdx = includeIdx; + m_kvTextEdited = false; + m_kvTextDirty = true; + m_selectKVTab = true; + } + ImGui::EndDisabled(); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Show the file in the KeyValues tab"); + + ImGui::SameLine(); + char pathBuffer[256]; + strncpy(pathBuffer, includePath, sizeof(pathBuffer) - 1); + pathBuffer[sizeof(pathBuffer) - 1] = 0; + + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::InputText("##includePath", pathBuffer, sizeof(pathBuffer), ImGuiInputTextFlags_EnterReturnsTrue) && pathBuffer[0]) + { + childSec.SetValue(pathBuffer, 1); + OnDocumentChanged(ref.doc); + } + ImGui::PopID(); + } + + if (removeKeyIdx != -1) + { + sec.RemoveSection(&sec.KeyAt(removeKeyIdx)); + OnDocumentChanged(ref.doc); + } + + char newPath[256]{}; + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::InputTextWithHint("##addInclude", "Add file, e.g. resources/ui_partial/menu_fonts.res", newPath, sizeof(newPath), ImGuiInputTextFlags_EnterReturnsTrue) && newPath[0]) + AddIncludeSection(m_selection, newPath, false); +} + +} + +#endif // EQUI_EDITOR + +//------------------------------------------------------------------------------------------------- + +void equiEditorUIDraw(bool& open) +{ +#ifdef EQUI_EDITOR + equi::Editor::s_uiEditor->Draw(open); +#endif +} + +bool equiEditorOpenFile(const char* filePath) +{ +#ifdef EQUI_EDITOR + return equi::Editor::s_uiEditor->LoadDocument(filePath); +#else + return false; +#endif +} + +void equiEditorShutdown() +{ +#ifdef EQUI_EDITOR + equi::Editor::s_uiEditor->Shutdown(); +#endif +} diff --git a/shared/equi/EqUI_Editor.h b/shared/equi/EqUI_Editor.h new file mode 100644 index 000000000..b679b0294 --- /dev/null +++ b/shared/equi/EqUI_Editor.h @@ -0,0 +1,21 @@ +////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) Inspiration Byte +// 2009-2025 +////////////////////////////////////////////////////////////////////////////////// +// Description: EqUI editor +////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#if !defined(_RETAIL) && !defined(_PROFILE) +#define EQUI_EDITOR +#endif + +// draws the editor window, used as an ImGui debug menu handler +void equiEditorUIDraw(bool& open); + +// opens a layout file in the editor +bool equiEditorOpenFile(const char* filePath); + +// destroys preview controls and targets, must be called before equi::Manager->Shutdown() +void equiEditorShutdown(); diff --git a/shared/equi/EqUI_EditorProps.cpp b/shared/equi/EqUI_EditorProps.cpp new file mode 100644 index 000000000..93fa9a4c0 --- /dev/null +++ b/shared/equi/EqUI_EditorProps.cpp @@ -0,0 +1,585 @@ +////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) Inspiration Byte +// 2009-2025 +////////////////////////////////////////////////////////////////////////////////// +// Description: EqUI editor - control property inspector +////////////////////////////////////////////////////////////////////////////////// + +#include "core/core_common.h" +#include "utils/KeyValues.h" + +#include "EqUI_Editor.h" +#include "EqUI_EditorProps.h" + +#ifdef EQUI_EDITOR +#include + +namespace equi::Editor +{ + +enum EPropType +{ + PROP_BOOL, + PROP_INT2, + PROP_FLOAT, + PROP_FLOAT2, + PROP_COLOR, + PROP_STRING, + PROP_VALUES, // raw list of values, e.g. font "Roboto" 20 bold + PROP_FLAGS, // any combination of option tokens + PROP_ENUM, // one of option tokens + PROP_FONT, // local font name or font description values +}; + +struct PropDesc +{ + const char* key; + EPropType type; + const char* className; // nullptr - property of every control + const char* const* options; + int numOptions; + float defValue[4]; // shown while key is not set +}; + +static const char* s_alignOptions[] = { "left", "hcenter", "right", "top", "vcenter", "bottom" }; +static const char* s_anchorOptions[] = { "left", "top", "right", "bottom" }; +static const char* s_scalingOptions[] = { "none", "inherit", "inherit_min", "inherit_max", "aspectw", "aspecth", "uniform", "aspect_min", "aspect_max" }; + +#define PROP_OPTIONS(arr) arr, elementsOf(arr) + +// keys parsed by IUIControl::Parse and the control classes, see equi_overview.md +static const PropDesc s_propDescs[] = { + { "position", PROP_INT2, nullptr, nullptr, 0, { 0, 0 } }, + { "size", PROP_INT2, nullptr, nullptr, 0, { 64, 64 } }, + { "visible", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, + { "selfVisible", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, + { "label", PROP_STRING, nullptr, nullptr, 0, {} }, + { "align", PROP_FLAGS, nullptr, PROP_OPTIONS(s_alignOptions), {} }, + { "anchors", PROP_FLAGS, nullptr, PROP_OPTIONS(s_anchorOptions), {} }, + { "scaling", PROP_ENUM, nullptr, PROP_OPTIONS(s_scalingOptions), {} }, + { "clipChilds", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, + { "clipTransform", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, + { "command", PROP_VALUES, nullptr, nullptr, 0, {} }, + { "font", PROP_FONT, nullptr, nullptr, 0, {} }, + { "fontScale", PROP_FLOAT2, nullptr, nullptr, 0, { 1, 1 } }, + { "textColor", PROP_COLOR, nullptr, nullptr, 0, { 1, 1, 1, 1 } }, + { "textAlign", PROP_FLAGS, nullptr, PROP_OPTIONS(s_alignOptions), {} }, + + { "color", PROP_COLOR, "panel", nullptr, 0, { 1, 1, 1, 1 } }, + { "window", PROP_BOOL, "panel", nullptr, 0, { 0 } }, + { "screenoverlay", PROP_BOOL, "panel", nullptr, 0, { 0 } }, + + { "file", PROP_STRING, "container", nullptr, 0, {} }, + + { "path", PROP_STRING, "image", nullptr, 0, {} }, + { "atlas", PROP_VALUES, "image", nullptr, 0, {} }, + { "uvLeftTop", PROP_FLOAT2, "image", nullptr, 0, { 0, 0 } }, + { "uvRightBottom", PROP_FLOAT2, "image", nullptr, 0, { 1, 1 } }, + { "color", PROP_COLOR, "image", nullptr, 0, { 1, 1, 1, 1 } }, + { "flipX", PROP_BOOL, "image", nullptr, 0, { 0 } }, + { "flipY", PROP_BOOL, "image", nullptr, 0, { 0 } }, + + { "image", PROP_STRING, "mask", nullptr, 0, {} }, + { "color", PROP_COLOR, "mask", nullptr, 0, { 1, 1, 1, 1 } }, + + { "value", PROP_FLOAT, "progressBar", nullptr, 0, { 0 } }, + { "color", PROP_COLOR, "progressBar", nullptr, 0, { 1, 1, 1, 1 } }, +}; + +static constexpr const ImU32 s_unsetKeyColor = IM_COL32(128, 128, 128, 255); + +//------------------------------------------------------------------------------------------------- + +static bool IsPropOfClass(const PropDesc& desc, const char* className) +{ + return !desc.className || !CString::CompareCaseIns(desc.className, className); +} + +static bool IsKnownKey(const char* key, const char* className) +{ + for (const PropDesc& desc : s_propDescs) + { + if (IsPropOfClass(desc, className) && !CString::CompareCaseIns(desc.key, key)) + return true; + } + return false; +} + +// creates the key or removes its values +static KVSection& ResetKey(KVSection& sec, KVSection*& key, const char* name) +{ + if (!key) + key = &sec.CreateSection(name); + key->ClearValues(); + return *key; +} + +static bool NeedsQuotes(const char* str) +{ + if (!*str) + return true; + + for (const char* c = str; *c; ++c) + { + if (CType::IsSpace(*c) || strchr("{};\"", *c)) + return true; + } + return false; +} + +static EqString ValuesToText(const KVSection* key) +{ + EqString text; + if (!key) + return text; + + for (int i = 0; i < key->ValueCount(); ++i) + { + const char* value = key->ValueAt(i).GetString(); + if (i > 0) + text.Append(' '); + + if (NeedsQuotes(value)) + text.Append(EqString::Format("\"%s\"", value)); + else + text.Append(value); + } + return text; +} + +// values text is parsed with the KeyValues parser, so quoting rules are the same as in .res files +static bool TextToValues(KVSection& key, const char* text) +{ + const EqString keyText = EqString::Format("v %s;", text); + + KVSection parsed; + if (!KeyValues::ParseText(keyText, keyText.Length(), parsed)) + return false; + + const KVSection* parsedKey = parsed.FindSection("v"); + if (!parsedKey) + return false; + + key.ClearValues(); + for (int i = 0; i < parsedKey->ValueCount(); ++i) + key.AddValue(parsedKey->ValueAt(i)); + + return true; +} + +static bool HasOption(const KVSection* key, const char* option) +{ + if (!key) + return false; + + for (EqStringRef value : key->Values()) + { + if (!value.CompareCaseIns(option)) + return true; + + // shortcuts accepted by the parser + if (!value.CompareCaseIns("center") && (!CString::CompareCaseIns(option, "hcenter") || !CString::CompareCaseIns(option, "vcenter"))) + return true; + if (!value.CompareCaseIns("all")) + return true; + } + return false; +} + +// replaces values of the key with parsed text, the key is created if it is valid +static bool ApplyValuesText(KVSection& sec, KVSection*& key, const char* name, const char* text) +{ + KVSection* newKey = key ? key : &sec.CreateSection(name); + if (!TextToValues(*newKey, text)) + { + MsgError("EqUI editor: can't parse values of '%s'\n", name); + if (!key) + sec.RemoveSection(newKey); + return false; + } + + key = newKey; + return true; +} + +//------------------------------------------------------------------------------------------------- + +static bool DrawTextInput(const char* id, const EqString& value, EqString& outValue) +{ + char buffer[512]; + strncpy(buffer, value.ToCString(), sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = 0; + + ImGui::SetNextItemWidth(-FLT_MIN); + if (!ImGui::InputText(id, buffer, sizeof(buffer), ImGuiInputTextFlags_EnterReturnsTrue)) + { + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Press Enter to apply"); + return false; + } + + outValue = buffer; + return true; +} + +// edits value of the key, creates it on change +static bool DrawPropertyValue(KVSection& sec, KVSection*& key, const PropDesc& desc, const PropertiesContext& ctx) +{ + ImGui::SetNextItemWidth(-FLT_MIN); + + switch (desc.type) + { + case PROP_BOOL: + { + bool value = KV_GetValueBool(key, 0, desc.defValue[0] > 0.0f); + if (!ImGui::Checkbox("##value", &value)) + return false; + + ResetKey(sec, key, desc.key).AddValue(value ? 1 : 0); + return true; + } + case PROP_INT2: + { + IVector2D value(desc.defValue[0], desc.defValue[1]); + if (key) + key->GetValues(value); + + if (!ImGui::DragInt2("##value", &value.x)) + return false; + + KVSection& newKey = ResetKey(sec, key, desc.key); + newKey.AddValue(value.x); + newKey.AddValue(value.y); + return true; + } + case PROP_FLOAT: + { + float value = KV_GetValueFloat(key, 0, desc.defValue[0]); + if (!ImGui::DragFloat("##value", &value, 0.01f)) + return false; + + ResetKey(sec, key, desc.key).AddValue(value); + return true; + } + case PROP_FLOAT2: + { + Vector2D value = KV_GetVector2D(key, 0, Vector2D(desc.defValue[0], desc.defValue[1])); + if (!ImGui::DragFloat2("##value", &value.x, 0.01f)) + return false; + + KVSection& newKey = ResetKey(sec, key, desc.key); + newKey.AddValue(value.x); + newKey.AddValue(value.y); + return true; + } + case PROP_COLOR: + { + Vector4D value = KV_GetVector4D(key, 0, Vector4D(desc.defValue[0], desc.defValue[1], desc.defValue[2], desc.defValue[3])); + if (!ImGui::ColorEdit4("##value", &value.x, ImGuiColorEditFlags_Float)) + return false; + + KVSection& newKey = ResetKey(sec, key, desc.key); + for (int i = 0; i < 4; ++i) + newKey.AddValue(value[i]); + return true; + } + case PROP_STRING: + { + EqString value; + if (!DrawTextInput("##value", KV_GetValueString(key, 0, ""), value)) + return false; + + ResetKey(sec, key, desc.key).AddValue(value.ToCString()); + return true; + } + case PROP_VALUES: + { + EqString value; + if (!DrawTextInput("##value", ValuesToText(key), value)) + return false; + + return ApplyValuesText(sec, key, desc.key, value); + } + case PROP_FONT: + { + // single value is a name from 'fonts', several values describe the font itself + bool changed = false; + const EqString current = ValuesToText(key); + + if (ImGui::BeginCombo("##localFont", current.Length() ? current.ToCString() : "(not set)")) + { + if (ctx.localFonts.numElem() == 0) + ImGui::TextDisabled("No 'fonts' in the layout"); + + for (const EqString& fontName : ctx.localFonts) + { + const bool selected = key && key->ValueCount() == 1 && !fontName.CompareCaseIns(key->ValueAt(0).GetString()); + if (ImGui::Selectable(fontName, selected) && !selected) + { + ResetKey(sec, key, desc.key).AddValue(fontName.ToCString()); + changed = true; + } + } + ImGui::EndCombo(); + } + + EqString value; + if (DrawTextInput("##value", current, value)) + changed = ApplyValuesText(sec, key, desc.key, value) || changed; + + return changed; + } + case PROP_FLAGS: + { + const ImGuiStyle& style = ImGui::GetStyle(); + const float maxX = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x; + + bool changed = false; + uint optionBits = 0; + for (int i = 0; i < desc.numOptions; ++i) + { + bool hasOption = HasOption(key, desc.options[i]); + + // options that don't fit go to the next line + const float optionWidth = ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(desc.options[i]).x; + if (i > 0 && ImGui::GetItemRectMax().x + style.ItemSpacing.x + optionWidth <= maxX) + ImGui::SameLine(); + + ImGui::PushID(i); + changed = ImGui::Checkbox(desc.options[i], &hasOption) || changed; + ImGui::PopID(); + + if (hasOption) + optionBits |= (1 << i); + } + + if (!changed) + return false; + + KVSection& newKey = ResetKey(sec, key, desc.key); + for (int i = 0; i < desc.numOptions; ++i) + { + if (optionBits & (1 << i)) + newKey.AddValue(desc.options[i]); + } + return true; + } + case PROP_ENUM: + { + const char* current = KV_GetValueString(key, 0, desc.options[0]); + if (!ImGui::BeginCombo("##value", current)) + return false; + + bool changed = false; + for (int i = 0; i < desc.numOptions; ++i) + { + const bool selected = !CString::CompareCaseIns(current, desc.options[i]); + if (ImGui::Selectable(desc.options[i], selected) && !selected) + { + ResetKey(sec, key, desc.key).AddValue(desc.options[i]); + changed = true; + } + } + ImGui::EndCombo(); + return changed; + } + } + return false; +} + +static void SetDefaultValue(KVSection& key, const PropDesc& desc, const PropertiesContext& ctx) +{ + switch (desc.type) + { + case PROP_BOOL: + key.AddValue(desc.defValue[0] > 0.0f ? 1 : 0); + break; + case PROP_INT2: + key.AddValue(static_cast(desc.defValue[0])); + key.AddValue(static_cast(desc.defValue[1])); + break; + case PROP_FLOAT: + key.AddValue(desc.defValue[0]); + break; + case PROP_FLOAT2: + key.AddValue(desc.defValue[0]); + key.AddValue(desc.defValue[1]); + break; + case PROP_COLOR: + for (int i = 0; i < 4; ++i) + key.AddValue(desc.defValue[i]); + break; + case PROP_STRING: + key.AddValue(""); + break; + case PROP_ENUM: + key.AddValue(desc.options[0]); + break; + case PROP_FONT: + key.AddValue(ctx.localFonts.numElem() ? ctx.localFonts[0].ToCString() : "default"); + break; + default: + break; + } +} + +// checkbox in the first column tells if the key is written to the layout +static bool DrawProperty(KVSection& sec, const PropDesc& desc, const PropertiesContext& ctx) +{ + KVSection* key = sec.FindSection(desc.key); + bool changed = false; + + ImGui::PushID(desc.key); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + + bool isSet = key != nullptr; + if (ImGui::Checkbox("##set", &isSet)) + { + if (isSet) + { + key = &sec.CreateSection(desc.key); + SetDefaultValue(*key, desc, ctx); + } + else + { + sec.RemoveSection(key); + key = nullptr; + } + changed = true; + } + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isSet ? "Remove key from the layout" : "Write key to the layout"); + + ImGui::SameLine(); + if (!key) + ImGui::PushStyleColor(ImGuiCol_Text, s_unsetKeyColor); + ImGui::TextUnformatted(desc.key); + if (!key) + ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); + changed = DrawPropertyValue(sec, key, desc, ctx) || changed; + + ImGui::PopID(); + return changed; +} + +// keys that the inspector does not know are edited as raw values +static bool DrawOtherKeys(KVSection& sec, const char* className) +{ + bool changed = false; + int removeKeyIdx = -1; + + for (int i = 0; i < sec.KeyCount(); ++i) + { + KVSection& key = sec.KeyAt(i); + if (!CString::CompareCaseIns(key.GetName(), "child") || !CString::CompareCaseIns(key.GetName(), EDITOR_ONLY_KEY)) + continue; + + if (IsKnownKey(key.GetName(), className)) + continue; + + ImGui::PushID(i); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + + if (ImGui::SmallButton("x")) + removeKeyIdx = i; + + ImGui::SameLine(); + ImGui::TextUnformatted(key.GetName()); + ImGui::TableNextColumn(); + + if (key.KeyCount() > 0) + { + // nested sections like transform and fonts + ImGui::TextDisabled("{ %d keys } - edit in KeyValues tab", key.KeyCount()); + } + else + { + EqString value; + if (DrawTextInput("##value", ValuesToText(&key), value)) + { + if (TextToValues(key, value)) + changed = true; + else + MsgError("EqUI editor: can't parse values of '%s'\n", key.GetName()); + } + } + ImGui::PopID(); + } + + if (removeKeyIdx != -1) + { + sec.RemoveSection(&sec.KeyAt(removeKeyIdx)); + changed = true; + } + + return changed; +} + +// adds keys written as in .res file: key value1 value2 +static bool DrawAddKey(KVSection& sec) +{ + EqString text; + ImGui::TextUnformatted("Add key"); + if (!DrawTextInput("##addKey", EqString(), text) || text.Length() == 0) + return false; + + const EqString keysText = EqString::Format("%s;", text.ToCString()); + + KVSection parsed; + if (!KeyValues::ParseText(keysText, keysText.Length(), parsed)) + { + MsgError("EqUI editor: can't parse key '%s'\n", text.ToCString()); + return false; + } + + for (const KVSection* parsedKey : parsed.keys) + { + KVSection* key = sec.FindSection(parsedKey->GetName()); + if (!key) + key = &sec.CreateSection(parsedKey->GetName()); + parsedKey->CopyTo(*key); + } + return parsed.KeyCount() > 0; +} + +//------------------------------------------------------------------------------------------------- + +bool IsEditorOnly(const KVSection& sec) +{ + return KV_GetValueBool(sec.FindSection(EDITOR_ONLY_KEY), 0, false); +} + +bool DrawControlProperties(KVSection& sec, const char* className, const PropertiesContext& ctx) +{ + bool changed = false; + + constexpr ImGuiTableFlags tableFlags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable; + if (ImGui::BeginTable("##properties", 2, tableFlags)) + { + ImGui::TableSetupColumn("Key", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + + for (const PropDesc& desc : s_propDescs) + { + if (IsPropOfClass(desc, className)) + changed = DrawProperty(sec, desc, ctx) || changed; + } + + changed = DrawOtherKeys(sec, className) || changed; + ImGui::EndTable(); + } + + ImGui::Separator(); + changed = DrawAddKey(sec) || changed; + + return changed; +} + +} + +#endif // EQUI_EDITOR diff --git a/shared/equi/EqUI_EditorProps.h b/shared/equi/EqUI_EditorProps.h new file mode 100644 index 000000000..85c773c09 --- /dev/null +++ b/shared/equi/EqUI_EditorProps.h @@ -0,0 +1,26 @@ +////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) Inspiration Byte +// 2009-2025 +////////////////////////////////////////////////////////////////////////////////// +// Description: EqUI editor - control property inspector +////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +struct KVSection; + +namespace equi::Editor +{ +// key of the editor-only child sections, they are shown in the preview and removed on save +static constexpr const char* EDITOR_ONLY_KEY = "editorOnly"; + +struct PropertiesContext +{ + const Array& localFonts; // names from 'fonts' sections of the layout available to the control +}; + +bool IsEditorOnly(const KVSection& sec); + +// draws property editors of the control layout section, returns true when the section is changed +bool DrawControlProperties(KVSection& sec, const char* className, const PropertiesContext& ctx); +} diff --git a/shared/equi/EqUI_Manager.cpp b/shared/equi/EqUI_Manager.cpp index 5b796759f..e05d2a0b1 100644 --- a/shared/equi/EqUI_Manager.cpp +++ b/shared/equi/EqUI_Manager.cpp @@ -8,6 +8,8 @@ #include "core/core_common.h" #include "core/ConCommand.h" #include "core/ConVar.h" +#include "core/IFileSystem.h" +#include "utils/KeyValues.h" #include "EqUI_Manager.h" #include "font/IFontCache.h" @@ -73,6 +75,7 @@ void CUIManager::Init() m_rootPanel = (Panel*)CreateElement("Panel"); m_rootPanel->SetName("equi_root"); + m_rootPanel->SetSelfVisible(false); m_rootPanel->Show(); m_defaultFont = g_fontCache->GetFont("default", 22); @@ -85,6 +88,20 @@ void CUIManager::Shutdown() m_panels.clear(true); } +bool CUIManager::LoadLayoutFile(const char* fileName, KVSection& outSec) const +{ + if (m_layoutFileLoader) + return m_layoutFileLoader(fileName, outSec); + + return KV_LoadFromFile(fileName, SP_MOD, outSec); +} + +void CUIManager::GetControlClassNames(Array& outNames) const +{ + for (const Factory& factory : m_controlFactory) + outNames.append(factory.name); +} + Panel* CUIManager::GetRootPanel() const { return m_rootPanel; diff --git a/shared/equi/EqUI_Manager.h b/shared/equi/EqUI_Manager.h index 128b115e7..d82f35e29 100644 --- a/shared/equi/EqUI_Manager.h +++ b/shared/equi/EqUI_Manager.h @@ -19,6 +19,7 @@ namespace equi class Panel; using ControlFactoryFn = IUIControl* (*)(); +using LayoutFileLoaderFn = EqFunction; class CUIManager { @@ -39,6 +40,9 @@ class CUIManager IUIControl* CreateElement( const char* pszTypeName ) const; + // names of registered control classes in registration order + void GetControlClassNames(Array& outNames) const; + void AddPanel( Panel* panel); void DestroyPanel( Panel* pPanel ); Panel* FindPanel( const char* pszPanelName ) const; @@ -65,6 +69,10 @@ class CUIManager void DumpPanelsToConsole() const; + // loads layout file included with 'child file', the loader replaces the file system when set (used by the editor) + bool LoadLayoutFile(const char* fileName, KVSection& outSec) const; + void SetLayoutFileLoader(const LayoutFileLoaderFn& loader) { m_layoutFileLoader = loader; } + IEqFont* GetDefaultFont() const {return m_defaultFont;} private: @@ -86,6 +94,8 @@ class CUIManager IUIControl* m_keyboardFocus{ nullptr }; IUIControl* m_mouseOver{ nullptr }; + LayoutFileLoaderFn m_layoutFileLoader; + IAARectangle m_viewFrameRect{ 0, 0, 640, 480 }; IVector2D m_mousePos{ 0 }; }; diff --git a/shared/equi/IEqUI_Control.cpp b/shared/equi/IEqUI_Control.cpp index cc019115f..f474aa232 100644 --- a/shared/equi/IEqUI_Control.cpp +++ b/shared/equi/IEqUI_Control.cpp @@ -350,7 +350,7 @@ void IUIControl::InitChildItems(const KVSection& sec, bool keepElements) if (!childClass.CompareCaseIns("file")) { KVSection section; - if (!KV_LoadFromFile(childName, SP_MOD, section)) + if (!equi::Manager->LoadLayoutFile(childName, section)) { MsgWarning("EqUI warning: file %s requested by element %s not found\n", childName.ToCString(), m_name.ToCString()); continue; @@ -787,7 +787,7 @@ void IUIControl::Render(int depth, RenderContextAbstract& context) // should we instead contain elements inside of parent scissor rectangle? rendPassRecorder->SetScissorRectangle(scissorRect); - if( m_parent && m_selfVisible ) + if( m_selfVisible ) { // paint control itself DrawSelf(clientRectRender, rendPassRecorder); diff --git a/shared/sys/scripting/sys_esl.cpp b/shared/sys/scripting/sys_esl.cpp index 2b8ce5fdf..2b03ff7b2 100644 --- a/shared/sys/scripting/sys_esl.cpp +++ b/shared/sys/scripting/sys_esl.cpp @@ -477,4 +477,5 @@ void eslSysTerm() { eslSysConsoleTerm(); eslSysInputTerm(); + eslSysEquiTerm(); } \ No newline at end of file diff --git a/shared/sys/scripting/sys_esl_equi.cpp b/shared/sys/scripting/sys_esl_equi.cpp index 7c0d4209d..8f69cce83 100644 --- a/shared/sys/scripting/sys_esl_equi.cpp +++ b/shared/sys/scripting/sys_esl_equi.cpp @@ -12,6 +12,12 @@ #include "sys_esl.h" #include "sys_esl_equi.h" +#include "equi/EqUI_Editor.h" + +#ifdef IMGUI_ENABLED +#include "imgui_backend/imgui_host.h" +#endif + EQSCRIPT_TYPE_BEGIN(EqWString) EQSCRIPT_TYPE_END @@ -437,5 +443,14 @@ bool eslSysEquiInit(const esl::ScriptState& state) state.SetGlobal("fonts", fontsTable); } +#ifdef IMGUI_ENABLED + g_imGuiHost->AddDebugMenu("ENGINE/EQUI/SCHEME EDITOR UI", equiEditorUIDraw); +#endif + return true; +} + +void eslSysEquiTerm() +{ + equiEditorShutdown(); } \ No newline at end of file diff --git a/shared/sys/scripting/sys_esl_equi.h b/shared/sys/scripting/sys_esl_equi.h index 1c9f9fdc9..05bbcb903 100644 --- a/shared/sys/scripting/sys_esl_equi.h +++ b/shared/sys/scripting/sys_esl_equi.h @@ -33,4 +33,5 @@ EQSCRIPT_BIND_TYPE_WITH_PARENT(equi::Container, equi::IUIControl, "equi::Contain EQSCRIPT_BIND_TYPE_WITH_PARENT(equi::Image, equi::IUIControl, "equi::Image") EQSCRIPT_BIND_TYPE_WITH_PARENT(equi::ProgressBar, equi::IUIControl, "equi::ProgressBar") -bool eslSysEquiInit(const esl::ScriptState& state); \ No newline at end of file +bool eslSysEquiInit(const esl::ScriptState& state); +void eslSysEquiTerm(); \ No newline at end of file From ee1a7ecfa4d8012b5ed945404b63f71f95f301a9 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 18:38:54 +0500 Subject: [PATCH 06/20] - equi: editor - pick drawn controls over containers, test children outside of the parent rectangle - equi: editor - add layout popup (alignment grid, anchors, position, size, scaling) on right click - equi: editor - move selected control with arrow keys - equi: editor - show position and size of the selected control in the preview - equi: editor - add reload of included files - docs: describe new EqUI editor features in equi_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/equi_overview.md | 13 ++- shared/equi/EqUI_Editor.cpp | 192 ++++++++++++++++++++++++++++++- shared/equi/EqUI_EditorProps.cpp | 138 ++++++++++++++++++++++ shared/equi/EqUI_EditorProps.h | 3 + 4 files changed, 340 insertions(+), 6 deletions(-) diff --git a/docs/equi_overview.md b/docs/equi_overview.md index c6ac58071..87b1e8305 100644 --- a/docs/equi_overview.md +++ b/docs/equi_overview.md @@ -305,7 +305,9 @@ registered. `equiEditorOpenFile(path)` opens a file from code. `InitChildItems` (the last section with the name wins), including nested includes. The preview is built with `SetLayoutFileLoader`, so it shows unsaved changes of included files. Save writes the layout and every changed included file; note that included files are often shared by - several layouts. The inspector lists `child file` entries of the control (edit path, remove, + several layouts. An included file can be reloaded alone (R in the inspector list, Reload in the + outliner menu of its controls, File → Reload included files for all), its unsaved changes are + discarded. The inspector lists `child file` entries of the control (edit path, remove, add, show in the KeyValues tab). `Container` `file` includes are not supported. - **Preview:** the panel is a child of the editor's own root panel, which is not added to the manager: it is not drawn by the host and does not receive game input. The editor renders it to @@ -317,7 +319,14 @@ registered. `equiEditorOpenFile(path)` opens a file from code. resolution is centered in the tab, has rulers in preview pixels and can be zoomed (10-400%, Ctrl + mouse wheel over the preview); the target keeps the resolution, only its display is scaled. Clicking a control - selects it; selection and bounds are drawn without control transforms. + selects it; selection and bounds are drawn without control transforms. The selection shows the + `position` and `size` of the control in layout units. Picking tests children + outside of the parent rectangle, and controls that draw nothing (`Container`, `HudElement`, + `selfVisible 0`) are picked only when no drawn control is under the cursor. Right click opens + the layout popup of the control (alignment grid, anchors, position, size, scaling; also the + Layout submenu of the outliner). Arrow keys move the selected control while the preview is + focused (Shift - by 10 units); with `right` / `bottom` alignment the position is inverted, as + it is an offset from that side. - **KeyValues tab:** the layout or a loaded included file as text; edits are applied back with Apply. - **Outliner:** the control tree. Controls written in included files are blue with the file diff --git a/shared/equi/EqUI_Editor.cpp b/shared/equi/EqUI_Editor.cpp index a88d65e6e..f1a115709 100644 --- a/shared/equi/EqUI_Editor.cpp +++ b/shared/equi/EqUI_Editor.cpp @@ -97,6 +97,7 @@ class CUIEditor void UpdateKVText(); void ClearIncludes(); + bool ReloadIncludeDoc(LayoutDoc& doc); LayoutDoc* GetIncludeDoc(const char* filePath); LayoutDoc* GetKVDoc(); @@ -126,6 +127,8 @@ class CUIEditor void DrawOutlinerNode(IUIControl* control); void DrawAddChildMenu(const SectionRef& ref, const EqString& path, bool editorOnly); void DrawPreviewTab(); + void DrawLayoutPopup(); + void MoveSelectedControl(const IVector2D& move); void DrawPreviewOverlay(const ImVec2& origin, float zoom, IUIControl* hovered); void DrawPreviewRulers(const ImVec2& origin, const IVector2D& size, float zoom, const ImVec2& rulerSize); void RenderPreview(const IVector2D& size); @@ -152,6 +155,9 @@ class CUIEditor EqString m_selection; bool m_hasSelection{ false }; + // control of the layout popup opened in the preview + EqString m_layoutPopupPath; + // outliner item under the mouse in the current frame, outliner is drawn before the preview EqString m_outlinerHoverPath; bool m_hasOutlinerHover{ false }; @@ -249,6 +255,34 @@ static void AddFontNames(const KVSection& sec, Array& outNames) } } +// containers and controls with selfVisible off draw nothing +static bool IsDrawnControl(const IUIControl* control) +{ + return control->IsSelfVisible() + && CString::CompareCaseIns(control->GetClassname(), "Container") + && CString::CompareCaseIns(control->GetClassname(), "HudElement"); +} + +// Unlike IUIControl::HitTest, children outside of the parent rectangle are tested, and controls +// that draw nothing are picked only when no drawn control is under the point. +static void EditorHitTest(IUIControl* control, const IVector2D& point, IUIControl*& drawnHit, IUIControl*& anyHit) +{ + if (!control->IsVisible()) + return; + + // first children are drawn on top + for (auto it = control->GetFirstChild(); !it.atEnd() && !drawnHit; ++it) + EditorHitTest(*it, point, drawnHit, anyHit); + + if (drawnHit || !control->GetClientRectangle().Contains(point)) + return; + + if (IsDrawnControl(control)) + drawnHit = control; + else if (!anyHit) + anyHit = control; +} + static void SetIntPair(KVSection& sec, const char* name, int x, int y) { KVSection& key = sec.CreateSection(name); @@ -470,6 +504,30 @@ void CUIEditor::ClearIncludes() m_includeDocs.clear(); } +// discards changes of the file, the file that failed to load is loaded again +bool CUIEditor::ReloadIncludeDoc(LayoutDoc& doc) +{ + IFileStreamPtr file = g_fileSystem->Open(doc.filePath, FS_OPEN_READ, SP_MOD); + KVSection root; + if (!file || !KeyValues::Parse(file, root)) + { + MsgError("EqUI editor: can't reload '%s'\n", doc.filePath.ToCString()); + return false; + } + + doc.root.Clear(); + root.CopyTo(doc.root); + doc.modified = false; + doc.valid = true; + + if (GetKVDoc() == &doc) + m_kvTextEdited = false; + + m_rebuildPending = true; + m_kvTextDirty = true; + return true; +} + LayoutDoc* CUIEditor::GetIncludeDoc(const char* filePath) { for (const LayoutDocPtr& doc : m_includeDocs) @@ -841,6 +899,12 @@ void CUIEditor::DrawMenuBar() if (ImGui::MenuItem("Reload", nullptr, false, m_mainDoc->filePath.Length() > 0)) LoadDocument(EqString(m_mainDoc->filePath)); + if (ImGui::MenuItem("Reload included files", nullptr, false, m_includeDocs.numElem() > 0)) + { + for (const LayoutDocPtr& doc : m_includeDocs) + ReloadIncludeDoc(*doc); + } + ImGui::Separator(); // included files are saved together with the layout @@ -1128,6 +1192,17 @@ void CUIEditor::DrawOutlinerNode(IUIControl* control) m_addFilePopupRequest = true; } + if (fromInclude && ImGui::MenuItem(EqString::Format("Reload %s", fnmPathExtractName(ref.doc->filePath).ToCString()))) + ReloadIncludeDoc(*ref.doc); + + ImGui::Separator(); + if (ImGui::BeginMenu("Layout")) + { + if (DrawLayoutProperties(*ref.sec)) + OnDocumentChanged(ref.doc); + ImGui::EndMenu(); + } + if (!isPanel) { ImGui::Separator(); @@ -1338,15 +1413,41 @@ void CUIEditor::DrawPreviewTab() if (previewHovered) { const ImVec2 mousePos = ImGui::GetMousePos(); - hovered = m_panel->HitTest(IVector2D(static_cast((mousePos.x - origin.x) / zoom), static_cast((mousePos.y - origin.y) / zoom))); + const IVector2D point(static_cast((mousePos.x - origin.x) / zoom), static_cast((mousePos.y - origin.y) / zoom)); + + IUIControl* drawnHit = nullptr; + IUIControl* anyHit = nullptr; + EditorHitTest(m_panel, point, drawnHit, anyHit); + hovered = drawnHit ? drawnHit : anyHit; } if (hovered && previewClicked) Select(GetControlPath(hovered)); - if (hovered) + if (hovered && ImGui::IsItemClicked(ImGuiMouseButton_Right)) + { + Select(GetControlPath(hovered)); + m_layoutPopupPath = m_selection; + ImGui::OpenPopup("##controlLayout"); + } + + if (hovered && !ImGui::IsPopupOpen("##controlLayout")) ImGui::SetTooltip("%s [%s]", hovered->GetName(), hovered->GetClassname()); + DrawLayoutPopup(); + + // arrows move the selected control, Shift moves by 10 + if (ImGui::IsWindowFocused() && !io.WantTextInput) + { + const int step = io.KeyShift ? 10 : 1; + const IVector2D move( + (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_RightArrow)) ? step : 0) - (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_LeftArrow)) ? step : 0), + (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_DownArrow)) ? step : 0) - (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_UpArrow)) ? step : 0)); + + if (move.x || move.y) + MoveSelectedControl(move); + } + // control under the mouse in the outliner is highlighted when visible if (!hovered && m_hasOutlinerHover) { @@ -1364,6 +1465,61 @@ void CUIEditor::DrawPreviewTab() ImGui::EndChild(); } +void CUIEditor::DrawLayoutPopup() +{ + if (!ImGui::BeginPopup("##controlLayout")) + return; + + // path is kept because the controls are recreated after every change + const SectionRef ref = FindSectionByPath(m_layoutPopupPath); + IUIControl* control = m_layoutPopupPath.Length() ? m_panel->Get(m_layoutPopupPath) : m_panel; + + if (control) + ImGui::Text("%s [%s]", control->GetName(), control->GetClassname()); + + ImGui::Separator(); + + if (ref.sec) + { + if (DrawLayoutProperties(*ref.sec)) + OnDocumentChanged(ref.doc); + } + else + { + ImGui::TextDisabled("Control has no layout section"); + } + + ImGui::EndPopup(); +} + +// position is the offset from the right and bottom sides with these alignments +void CUIEditor::MoveSelectedControl(const IVector2D& move) +{ + const IUIControl* control = GetSelectedControl(); + const SectionRef ref = FindSectionByPath(m_selection); + if (!control || !ref.sec) + return; + + const int alignment = control->GetAlignment(); + const IVector2D offset( + (alignment & UI_ALIGN_RIGHT) ? -move.x : move.x, + (alignment & UI_ALIGN_BOTTOM) ? -move.y : move.y); + + KVSection* key = ref.sec->FindSection("position"); + IVector2D position(0); + if (key) + key->GetValues(position); + else + key = &ref.sec->CreateSection("position"); + + position += offset; + key->ClearValues(); + key->AddValue(position.x); + key->AddValue(position.y); + + OnDocumentChanged(ref.doc); +} + void CUIEditor::DrawPreviewRulers(const ImVec2& origin, const IVector2D& size, float zoom, const ImVec2& rulerSize) { const ImVec2 displaySize(floorf(size.x * zoom), floorf(size.y * zoom)); @@ -1448,8 +1604,27 @@ void CUIEditor::DrawPreviewOverlay(const ImVec2& origin, float zoom, IUIControl* if (hovered) drawControlRect(hovered, s_hoverColor, 1.0f); - if (const IUIControl* selected = GetSelectedControl()) - drawControlRect(selected, s_selectionColor, 2.0f); + const IUIControl* selected = GetSelectedControl(); + if (!selected) + return; + + drawControlRect(selected, s_selectionColor, 2.0f); + + // layout values as written in the file, above the rectangle or below it near the top edge + const IVector2D position = selected->GetPosition(); + const IVector2D size = selected->GetSize(); + const EqString label = EqString::Format("pos X: %d Y: %d size W: %d H: %d", position.x, position.y, size.x, size.y); + + const IAARectangle rect = selected->GetClientRectangle(); + const ImVec2 textSize = ImGui::CalcTextSize(label); + const float padding = 2.0f; + + ImVec2 labelPos(origin.x + floorf(rect.leftTop.x * zoom), origin.y + floorf(rect.leftTop.y * zoom) - textSize.y - padding * 2.0f); + if (labelPos.y < ImGui::GetWindowPos().y) + labelPos.y = origin.y + floorf(rect.rightBottom.y * zoom) + padding * 2.0f; + + drawList->AddRectFilled(ImVec2(labelPos.x - padding, labelPos.y - padding), ImVec2(labelPos.x + textSize.x + padding, labelPos.y + textSize.y + padding), IM_COL32(0, 0, 0, 200)); + drawList->AddText(labelPos, s_selectionColor, label); } void CUIEditor::DrawKeyValuesTab() @@ -1661,6 +1836,15 @@ void CUIEditor::DrawIncludes(const SectionRef& ref) if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show the file in the KeyValues tab"); + ImGui::SameLine(); + ImGui::BeginDisabled(includeIdx == -1); + if (ImGui::SmallButton("R")) + ReloadIncludeDoc(*m_includeDocs[includeIdx]); + ImGui::EndDisabled(); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Reload the file from disk, its unsaved changes are lost"); + ImGui::SameLine(); char pathBuffer[256]; strncpy(pathBuffer, includePath, sizeof(pathBuffer) - 1); diff --git a/shared/equi/EqUI_EditorProps.cpp b/shared/equi/EqUI_EditorProps.cpp index 93fa9a4c0..2d227f733 100644 --- a/shared/equi/EqUI_EditorProps.cpp +++ b/shared/equi/EqUI_EditorProps.cpp @@ -547,6 +547,101 @@ static bool DrawAddKey(KVSection& sec) return parsed.KeyCount() > 0; } +static const PropDesc& GetCommonPropDesc(const char* key) +{ + for (const PropDesc& desc : s_propDescs) + { + if (!desc.className && !CString::CompareCaseIns(desc.key, key)) + return desc; + } + + ASSERT_FAIL("unknown property %s", key); + return s_propDescs[0]; +} + +// 3x3 grid, missing key is left top like in IUIControl +static bool DrawAlignmentGrid(KVSection& sec) +{ + static const char* s_hAlign[] = { "left", "hcenter", "right" }; + static const char* s_vAlign[] = { "top", "vcenter", "bottom" }; + + KVSection* key = sec.FindSection("align"); + + int curH = 0; + int curV = 0; + if (key) + { + curH = HasOption(key, "hcenter") ? 1 : (HasOption(key, "right") ? 2 : 0); + curV = HasOption(key, "vcenter") ? 1 : (HasOption(key, "bottom") ? 2 : 0); + } + + bool changed = false; + for (int v = 0; v < 3; ++v) + { + for (int h = 0; h < 3; ++h) + { + if (h > 0) + ImGui::SameLine(); + + ImGui::PushID(v * 3 + h); + if (ImGui::RadioButton("##align", h == curH && v == curV) && !(h == curH && v == curV)) + { + KVSection& newKey = ResetKey(sec, key, "align"); + newKey.AddValue(s_hAlign[h]); + newKey.AddValue(s_vAlign[v]); + changed = true; + } + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s %s", s_hAlign[h], s_vAlign[v]); + ImGui::PopID(); + } + } + return changed; +} + +// checkboxes placed on the sides of a square +static bool DrawAnchorsCross(KVSection& sec) +{ + KVSection* key = sec.FindSection("anchors"); + + bool sides[4]; + for (int i = 0; i < 4; ++i) + sides[i] = HasOption(key, s_anchorOptions[i]); + + const float boxSize = ImGui::GetFrameHeight(); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float startX = ImGui::GetCursorPosX(); + + // left, top, right, bottom + bool changed = false; + const auto drawSide = [&](int side, float offsetX) { + ImGui::SetCursorPosX(startX + offsetX); + ImGui::PushID(side); + changed = ImGui::Checkbox("##anchor", &sides[side]) || changed; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", s_anchorOptions[side]); + ImGui::PopID(); + }; + + drawSide(1, boxSize + spacing); + drawSide(0, 0.0f); + ImGui::SameLine(); + drawSide(2, (boxSize + spacing) * 2.0f); + drawSide(3, boxSize + spacing); + + if (!changed) + return false; + + KVSection& newKey = ResetKey(sec, key, "anchors"); + for (int i = 0; i < 4; ++i) + { + if (sides[i]) + newKey.AddValue(s_anchorOptions[i]); + } + return true; +} + //------------------------------------------------------------------------------------------------- bool IsEditorOnly(const KVSection& sec) @@ -580,6 +675,49 @@ bool DrawControlProperties(KVSection& sec, const char* className, const Properti return changed; } +bool DrawLayoutProperties(KVSection& sec) +{ + static const Array s_noFonts{ PP_SL }; + const PropertiesContext ctx{ s_noFonts }; + + bool changed = false; + + constexpr ImGuiTableFlags pickerFlags = ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("##layoutPickers", 2, pickerFlags)) + { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextUnformatted("Align"); + changed = DrawAlignmentGrid(sec) || changed; + + ImGui::TableNextColumn(); + ImGui::TextUnformatted("Anchors"); + changed = DrawAnchorsCross(sec) || changed; + + ImGui::EndTable(); + } + + // popups and menus are sized by their content, values take the remaining width + constexpr float labelWidth = 70.0f; + constexpr float valueWidth = 180.0f; + for (const char* keyName : { "position", "size", "scaling" }) + { + const PropDesc& desc = GetCommonPropDesc(keyName); + KVSection* key = sec.FindSection(desc.key); + + ImGui::PushID(keyName); + ImGui::TextUnformatted(keyName); + ImGui::SameLine(labelWidth); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(valueWidth, 0.0f)); + changed = DrawPropertyValue(sec, key, desc, ctx) || changed; + ImGui::EndGroup(); + ImGui::PopID(); + } + + return changed; +} + } #endif // EQUI_EDITOR diff --git a/shared/equi/EqUI_EditorProps.h b/shared/equi/EqUI_EditorProps.h index 85c773c09..ecd6c39d5 100644 --- a/shared/equi/EqUI_EditorProps.h +++ b/shared/equi/EqUI_EditorProps.h @@ -23,4 +23,7 @@ bool IsEditorOnly(const KVSection& sec); // draws property editors of the control layout section, returns true when the section is changed bool DrawControlProperties(KVSection& sec, const char* className, const PropertiesContext& ctx); + +// alignment and anchors as visual pickers, position, size and scaling; returns true when the section is changed +bool DrawLayoutProperties(KVSection& sec); } From 202d90f4072401f825c51476549e5ff81076d391 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 18:53:44 +0500 Subject: [PATCH 07/20] - equi: editor - add transform property (T, R, S) to the inspector and the layout popup - equi: editor - fix EqString passed to format string in the inspector file label - docs: describe transform editing in equi_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/equi_overview.md | 5 ++- shared/equi/EqUI_Editor.cpp | 2 +- shared/equi/EqUI_EditorProps.cpp | 66 +++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/docs/equi_overview.md b/docs/equi_overview.md index 87b1e8305..3c7f87c91 100644 --- a/docs/equi_overview.md +++ b/docs/equi_overview.md @@ -323,7 +323,7 @@ registered. `equiEditorOpenFile(path)` opens a file from code. `position` and `size` of the control in layout units. Picking tests children outside of the parent rectangle, and controls that draw nothing (`Container`, `HudElement`, `selfVisible 0`) are picked only when no drawn control is under the cursor. Right click opens - the layout popup of the control (alignment grid, anchors, position, size, scaling; also the + the layout popup of the control (alignment grid, anchors, position, size, scaling, transform; also the Layout submenu of the outliner). Arrow keys move the selected control while the preview is focused (Shift - by 10 units); with `right` / `bottom` alignment the position is inverted, as it is an offset from that side. @@ -344,7 +344,8 @@ registered. `equiEditorOpenFile(path)` opens a file from code. editors; a checkbox writes or removes the key, flag options wrap to the next line when the column is narrow. `font` has a list of local fonts: names from `fonts` sections of the control, its parents and their included files. Other keys are edited as raw values written as - in `.res` files; nested sections (`transform`, `fonts`) are edited in the KeyValues tab. + in `.res` files. `transform` has its own editor (T, R, S: `translate`, `rotate`, + `elementScale`); other nested sections (`fonts`) are edited in the KeyValues tab. - **Editor-only controls:** child sections with `editorOnly 1;` are shown in the preview (for example sample content for a list) and removed from the file on save. This includes `child file "" { editorOnly 1; }` (Add child file... has an Editor only option, the diff --git a/shared/equi/EqUI_Editor.cpp b/shared/equi/EqUI_Editor.cpp index f1a115709..9f9106e50 100644 --- a/shared/equi/EqUI_Editor.cpp +++ b/shared/equi/EqUI_Editor.cpp @@ -1729,7 +1729,7 @@ void CUIEditor::DrawInspector() if (ref.doc != m_mainDoc.Ptr()) { - ImGui::TextColored(s_includeTextColor, "File: %s%s", ref.doc->filePath, ref.doc->modified ? "*" : ""); + ImGui::TextColored(s_includeTextColor, "File: %s%s", ref.doc->filePath.ToCString(), ref.doc->modified ? "*" : ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Control is written in the included file, the file is saved with the layout"); } diff --git a/shared/equi/EqUI_EditorProps.cpp b/shared/equi/EqUI_EditorProps.cpp index 2d227f733..2b24e2e76 100644 --- a/shared/equi/EqUI_EditorProps.cpp +++ b/shared/equi/EqUI_EditorProps.cpp @@ -29,6 +29,7 @@ enum EPropType PROP_FLAGS, // any combination of option tokens PROP_ENUM, // one of option tokens PROP_FONT, // local font name or font description values + PROP_TRANSFORM, // nested section: rotate, translate, elementScale }; struct PropDesc @@ -57,6 +58,7 @@ static const PropDesc s_propDescs[] = { { "align", PROP_FLAGS, nullptr, PROP_OPTIONS(s_alignOptions), {} }, { "anchors", PROP_FLAGS, nullptr, PROP_OPTIONS(s_anchorOptions), {} }, { "scaling", PROP_ENUM, nullptr, PROP_OPTIONS(s_scalingOptions), {} }, + { "transform", PROP_TRANSFORM, nullptr, nullptr, 0, {} }, { "clipChilds", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, { "clipTransform", PROP_BOOL, nullptr, nullptr, 0, { 1 } }, { "command", PROP_VALUES, nullptr, nullptr, 0, {} }, @@ -186,6 +188,14 @@ static bool HasOption(const KVSection* key, const char* option) return false; } +static void SetFloatValues(KVSection& sec, const char* name, const float* values, int count) +{ + KVSection* key = sec.FindSection(name); + KVSection& newKey = ResetKey(sec, key, name); + for (int i = 0; i < count; ++i) + newKey.AddValue(values[i]); +} + // replaces values of the key with parsed text, the key is created if it is valid static bool ApplyValuesText(KVSection& sec, KVSection*& key, const char* name, const char* text) { @@ -329,6 +339,50 @@ static bool DrawPropertyValue(KVSection& sec, KVSection*& key, const PropDesc& d return changed; } + case PROP_TRANSFORM: + { + // same keys and defaults as in IUIControl::Parse + float rotate = KV_GetValueFloat(key ? key->FindSection("rotate") : nullptr, 0, 0.0f); + Vector2D translate = KV_GetVector2D(key ? key->FindSection("translate") : nullptr, 0, vec2_zero); + Vector2D elementScale = KV_GetVector2D(key ? key->FindSection("elementScale") : nullptr, 0, Vector2D(1.0f)); + + // short labels to fit narrow columns and menus, key names are in the tooltips + const float startX = ImGui::GetCursorPosX(); + const float labelWidth = ImGui::CalcTextSize("T").x + ImGui::GetStyle().ItemSpacing.x; + const auto beginRow = [&](const char* label, const char* tooltip) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", tooltip); + ImGui::SameLine(startX + labelWidth); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + beginRow("T", "translate"); + const bool translateChanged = ImGui::DragFloat2("##translate", &translate.x, 0.5f); + + beginRow("R", "rotate, degrees"); + const bool rotateChanged = ImGui::DragFloat("##rotate", &rotate, 0.5f, 0.0f, 0.0f, "%.1f"); + + beginRow("S", "elementScale"); + const bool scaleChanged = ImGui::DragFloat2("##elementScale", &elementScale.x, 0.01f); + + if (!rotateChanged && !translateChanged && !scaleChanged) + return false; + + // nested keys are kept, only the changed one is written + if (!key) + key = &sec.CreateSection(desc.key); + + if (rotateChanged) + SetFloatValues(*key, "rotate", &rotate, 1); + if (translateChanged) + SetFloatValues(*key, "translate", &translate.x, 2); + if (scaleChanged) + SetFloatValues(*key, "elementScale", &elementScale.x, 2); + + return true; + } case PROP_FLAGS: { const ImGuiStyle& style = ImGui::GetStyle(); @@ -418,6 +472,16 @@ static void SetDefaultValue(KVSection& key, const PropDesc& desc, const Properti case PROP_FONT: key.AddValue(ctx.localFonts.numElem() ? ctx.localFonts[0].ToCString() : "default"); break; + case PROP_TRANSFORM: + { + const float rotate = 0.0f; + const float translate[] = { 0.0f, 0.0f }; + const float elementScale[] = { 1.0f, 1.0f }; + SetFloatValues(key, "rotate", &rotate, 1); + SetFloatValues(key, "translate", translate, 2); + SetFloatValues(key, "elementScale", elementScale, 2); + break; + } default: break; } @@ -700,7 +764,7 @@ bool DrawLayoutProperties(KVSection& sec) // popups and menus are sized by their content, values take the remaining width constexpr float labelWidth = 70.0f; constexpr float valueWidth = 180.0f; - for (const char* keyName : { "position", "size", "scaling" }) + for (const char* keyName : { "position", "size", "scaling", "transform" }) { const PropDesc& desc = GetCommonPropDesc(keyName); KVSection* key = sec.FindSection(desc.key); From 86ecfba550a1800fadfe985a4dbfbed576fecee5 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 17:07:02 +0300 Subject: [PATCH 08/20] - equi: editor - fix transform UI too smol --- shared/equi/EqUI_EditorProps.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shared/equi/EqUI_EditorProps.cpp b/shared/equi/EqUI_EditorProps.cpp index 2b24e2e76..af58ec515 100644 --- a/shared/equi/EqUI_EditorProps.cpp +++ b/shared/equi/EqUI_EditorProps.cpp @@ -347,14 +347,12 @@ static bool DrawPropertyValue(KVSection& sec, KVSection*& key, const PropDesc& d Vector2D elementScale = KV_GetVector2D(key ? key->FindSection("elementScale") : nullptr, 0, Vector2D(1.0f)); // short labels to fit narrow columns and menus, key names are in the tooltips - const float startX = ImGui::GetCursorPosX(); - const float labelWidth = ImGui::CalcTextSize("T").x + ImGui::GetStyle().ItemSpacing.x; const auto beginRow = [&](const char* label, const char* tooltip) { - ImGui::AlignTextToFramePadding(); + ImGui::SetNextItemWidth(40); ImGui::TextUnformatted(label); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tooltip); - ImGui::SameLine(startX + labelWidth); + ImGui::SameLine(45); ImGui::SetNextItemWidth(-FLT_MIN); }; From 41e1f26f0a898b5535006e1843ad452874918391 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Sun, 1 Nov 2026 22:12:08 +0300 Subject: [PATCH 09/20] - equi: editor property filter --- shared/equi/EqUI_Editor.cpp | 7 ++++++- shared/equi/EqUI_EditorProps.cpp | 18 +++++++++++++----- shared/equi/EqUI_EditorProps.h | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/shared/equi/EqUI_Editor.cpp b/shared/equi/EqUI_Editor.cpp index 9f9106e50..adc456824 100644 --- a/shared/equi/EqUI_Editor.cpp +++ b/shared/equi/EqUI_Editor.cpp @@ -165,6 +165,9 @@ class CUIEditor // controls hidden in the preview only, not written to the document Array m_editorHiddenPaths{ PP_SL }; + // inspector + char m_propertyFilter[64]{}; + // KeyValues tab, -1 is the main document, otherwise include index Array m_kvText{ PP_SL }; int m_kvDocIdx{ -1 }; @@ -1775,7 +1778,9 @@ void CUIEditor::DrawInspector() CollectLocalFonts(m_selection, localFonts); - const PropertiesContext propsCtx{ localFonts }; + ImGui::InputTextWithHint("##filter", "Filter", m_propertyFilter, sizeof(m_propertyFilter)); + const PropertiesContext propsCtx{ localFonts, m_propertyFilter }; + if (DrawControlProperties(*sec, className, propsCtx)) OnDocumentChanged(ref.doc); diff --git a/shared/equi/EqUI_EditorProps.cpp b/shared/equi/EqUI_EditorProps.cpp index af58ec515..0a8f016f2 100644 --- a/shared/equi/EqUI_EditorProps.cpp +++ b/shared/equi/EqUI_EditorProps.cpp @@ -529,7 +529,7 @@ static bool DrawProperty(KVSection& sec, const PropDesc& desc, const PropertiesC } // keys that the inspector does not know are edited as raw values -static bool DrawOtherKeys(KVSection& sec, const char* className) +static bool DrawOtherKeys(const PropertiesContext& ctx, KVSection& sec, const char* className) { bool changed = false; int removeKeyIdx = -1; @@ -543,6 +543,9 @@ static bool DrawOtherKeys(KVSection& sec, const char* className) if (IsKnownKey(key.GetName(), className)) continue; + if (ctx.filter.Length() > 0 && _Es(key.GetName()).Find(ctx.filter, false, 0) != -1) + continue; + ImGui::PushID(i); ImGui::TableNextRow(); ImGui::TableNextColumn(); @@ -710,7 +713,7 @@ bool IsEditorOnly(const KVSection& sec) { return KV_GetValueBool(sec.FindSection(EDITOR_ONLY_KEY), 0, false); } - +PRAGMA_OPTIMIZE_OFF bool DrawControlProperties(KVSection& sec, const char* className, const PropertiesContext& ctx) { bool changed = false; @@ -723,11 +726,16 @@ bool DrawControlProperties(KVSection& sec, const char* className, const Properti for (const PropDesc& desc : s_propDescs) { - if (IsPropOfClass(desc, className)) - changed = DrawProperty(sec, desc, ctx) || changed; + if (!IsPropOfClass(desc, className)) + continue; + + if (ctx.filter.Length() > 0 && _Es(desc.key).Find(ctx.filter, false, 0) == -1) + continue; + + changed = DrawProperty(sec, desc, ctx) || changed; } - changed = DrawOtherKeys(sec, className) || changed; + changed = DrawOtherKeys(ctx, sec, className) || changed; ImGui::EndTable(); } diff --git a/shared/equi/EqUI_EditorProps.h b/shared/equi/EqUI_EditorProps.h index ecd6c39d5..eb0717145 100644 --- a/shared/equi/EqUI_EditorProps.h +++ b/shared/equi/EqUI_EditorProps.h @@ -17,6 +17,7 @@ static constexpr const char* EDITOR_ONLY_KEY = "editorOnly"; struct PropertiesContext { const Array& localFonts; // names from 'fonts' sections of the layout available to the control + EqStringRef filter; }; bool IsEditorOnly(const KVSection& sec); From caab6773bc048f60b6ee63a09de6d72e7b8950a2 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Mon, 2 Nov 2026 23:12:42 +0500 Subject: [PATCH 10/20] - sys: apply window title from SetWindowTitle after start-up (ApplyWindowTitle) - sys: create CHostViewport depth target with the sampler of the default depth buffer - docs: describe runtime window title and viewport depth sampler in sys_application_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/sys_application_overview.md | 8 +++++--- shared/sys/sys_host.cpp | 24 +++++++++++++++++------- shared/sys/sys_host.h | 1 + shared/sys/sys_viewport.cpp | 2 ++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/sys_application_overview.md b/docs/sys_application_overview.md index edb680a9c..f33664b5b 100644 --- a/docs/sys_application_overview.md +++ b/docs/sys_application_overview.md @@ -158,8 +158,9 @@ frame the loop sleeps `sys_sleep` ms (default 1). - `SetFullscreenMode(screenSize)`, `SetWindowedMode()`, `ToggleFullscreen()` (also Right Alt + Enter), `ApplyVideoMode()`, `GetVideoModes(list)`, `IsWindowed()`; commands `sys_set_fullscreen`, `sys_set_windowed`, `sys_vmode_list`. -- `SetWindowTitle(title)`; non-retail builds append the renderer, configuration, platform and build - number. +- `SetWindowTitle(title)` can be called at any time, the title is applied when the window and the + renderer exist (`InitAppStates` runs before them); non-retail builds append the renderer, + configuration, platform and build number. - Text input for on-screen keyboards: `RequestTextInput()`, `ReleaseTextInput()`, `IsTextInputShown()`. - Cursor: controlled by the current state (`GetMouseCursorProperties`); it is always shown and @@ -221,7 +222,8 @@ g_renderAPI->BeginRenderPass( .End()); ``` -- `DrawImGui(size)` resizes the color and depth targets (backbuffer formats, `ResizeRenderTarget`) +- `DrawImGui(size)` resizes the color and depth targets (backbuffer formats and samplers, so the + depth target can be sampled like the default depth buffer; `ResizeRenderTarget`) to the drawn size and puts an invisible button with the image in the current ImGui window. The viewport is *visible* only in a frame where `DrawImGui` was called. - Viewports are disabled in `Retail` and `Profile` builds (`HOST_VIEWPORT_ENABLED` in diff --git a/shared/sys/sys_host.cpp b/shared/sys/sys_host.cpp index 7c367d710..d778bdb53 100644 --- a/shared/sys/sys_host.cpp +++ b/shared/sys/sys_host.cpp @@ -296,6 +296,22 @@ bool CGameHost::LoadModules() void CGameHost::SetWindowTitle(const char* windowTitle) { m_windowTitle = windowTitle; + ApplyWindowTitle(); +} + +void CGameHost::ApplyWindowTitle() +{ + // non-retail title shows the renderer, so it is applied after the material system is initialized + if (!m_window || !g_renderAPI) + return; + +#ifdef _RETAIL + SDL_SetWindowTitle(m_window, m_windowTitle); +#else + SDL_SetWindowTitle(m_window, EqString::Format( + "%s | %s | " COMPILE_CONFIGURATION " (" COMPILE_PLATFORM ") | build %d (" COMPILE_DATE ")", + m_windowTitle.ToCString(), g_renderAPI->GetRendererName(), BUILD_NUMBER_ENGINE)); +#endif } bool CGameHost::IsWindowed() const @@ -622,13 +638,7 @@ bool CGameHost::InitSystems() REGISTER_INTERNAL_SHADERS(); g_fullScreenEdgeAA->Init(); -#ifdef _RETAIL - SDL_SetWindowTitle(m_window, m_windowTitle); -#else - SDL_SetWindowTitle(m_window, EqString::Format( - "%s | %s | " COMPILE_CONFIGURATION " (" COMPILE_PLATFORM ") | build %d (" COMPILE_DATE ")", - m_windowTitle.ToCString(), g_renderAPI->GetRendererName(), BUILD_NUMBER_ENGINE)); -#endif + ApplyWindowTitle(); return true; } diff --git a/shared/sys/sys_host.h b/shared/sys/sys_host.h index fe960e444..f7d8f1015 100644 --- a/shared/sys/sys_host.h +++ b/shared/sys/sys_host.h @@ -99,6 +99,7 @@ class CGameHost void UpdateCursorState(); void SetCursorShow(bool bShow); + void ApplyWindowTitle(); void BeginScene(); void EndScene(); diff --git a/shared/sys/sys_viewport.cpp b/shared/sys/sys_viewport.cpp index 5402b5816..2e80c40cb 100644 --- a/shared/sys/sys_viewport.cpp +++ b/shared/sys/sys_viewport.cpp @@ -51,11 +51,13 @@ void CHostViewport::UpdateTargets(const IVector2D& size) .End() ); + // states sample scene depth like the default depth buffer m_depthTarget = g_renderAPI->CreateRenderTarget( Builder() .Name(EqString::Format("_hvp_%s_depth", m_name)) .Size(size.x, size.y) .Format(g_matSystem->GetDefaultDepthBuffer()->GetFormat()) + .Sampler(sampler) .End() ); return; From d55af69339515493479d54cc78eabd829e22a5e0 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Tue, 3 Nov 2026 01:48:07 +0500 Subject: [PATCH 11/20] - sys: SetWindowedMode accounts vid_maximized setting --- docs/sys_application_overview.md | 4 +++- shared/sys/sys_host.cpp | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/sys_application_overview.md b/docs/sys_application_overview.md index f33664b5b..09f3e5db6 100644 --- a/docs/sys_application_overview.md +++ b/docs/sys_application_overview.md @@ -153,8 +153,10 @@ frame the loop sleeps `sys_sleep` ms (default 1). ## Window and video modes -- ConVars: `vid_mode` (`"1280x720"`), `vid_fullscreen`, `vid_screen`, `vid_bpp`, +- ConVars: `vid_mode` (`"1280x720"`), `vid_fullscreen`, `vid_screen`, `vid_maximized`, `vid_bpp`, `r_antialiasing`. +- `vid_maximized` creates the window maximized; `SetWindowedMode()` keeps the size of a maximized + window instead of applying `vid_mode`. - `SetFullscreenMode(screenSize)`, `SetWindowedMode()`, `ToggleFullscreen()` (also Right Alt + Enter), `ApplyVideoMode()`, `GetVideoModes(list)`, `IsWindowed()`; commands `sys_set_fullscreen`, `sys_set_windowed`, `sys_vmode_list`. diff --git a/shared/sys/sys_host.cpp b/shared/sys/sys_host.cpp index d778bdb53..6ab61338d 100644 --- a/shared/sys/sys_host.cpp +++ b/shared/sys/sys_host.cpp @@ -366,14 +366,22 @@ void CGameHost::SetWindowedMode() int screen = 0; sysHostGetWindowProperties(fullscreen, screen, adjustedWide, adjustedTall); + // maximized window (vid_maximized) keeps its size: resizing it leaves the window maximized but not filling the screen + const bool maximized = (SDL_GetWindowFlags(m_window) & SDL_WINDOW_MAXIMIZED) != 0; + if (maximized) + SDL_GetWindowSize(m_window, &adjustedWide, &adjustedTall); + OnWindowResize(adjustedWide, adjustedTall); if (!g_matSystem->IsInitialized() || g_matSystem->SetWindowed(true)) { Msg("Set %dx%d mode (windowed)\n", adjustedWide, adjustedTall); SDL_SetWindowFullscreen(m_window, 0); - SDL_SetWindowSize(m_window, adjustedWide, adjustedTall); - SDL_SetWindowPosition(m_window, adjustedPosX, adjustedPosY); + if (!maximized) + { + SDL_SetWindowSize(m_window, adjustedWide, adjustedTall); + SDL_SetWindowPosition(m_window, adjustedPosX, adjustedPosY); + } } } From fa19b651815ee0870df5ba45a761188ef37a4493 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 00:33:30 +0500 Subject: [PATCH 12/20] - math: fix ConvexHull2D skipping 3 points --- framework/math/Utility.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/math/Utility.cpp b/framework/math/Utility.cpp index bb02b3436..8bae27d0f 100644 --- a/framework/math/Utility.cpp +++ b/framework/math/Utility.cpp @@ -506,7 +506,7 @@ static float ConvexOrient2D(const Vector2D& o, const Vector2D& a, const Vector2D void ConvexHull2D(Array& points, Array& hull) { const int n = points.numElem(); - if (n <= 3) + if (n < 3) return; int k = 0; From caddb9d1d6828ea3529c6978e6bc202234021d18 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 01:03:40 +0500 Subject: [PATCH 13/20] - imgui: matsystem backend - split draw commands into chunks that fit dynamic meshes - imgui: matsystem backend - enable ImGuiBackendFlags_RendererHasVtxOffset - docs: describe ImGui renderer backend limits in sys_application_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/sys_application_overview.md | 6 + shared/imgui_backend/imgui_impl_matsystem.cpp | 187 ++++++++++++++---- 2 files changed, 154 insertions(+), 39 deletions(-) diff --git a/docs/sys_application_overview.md b/docs/sys_application_overview.md index 09f3e5db6..f2d69cd52 100644 --- a/docs/sys_application_overview.md +++ b/docs/sys_application_overview.md @@ -148,6 +148,12 @@ frame the loop sleeps `sys_sleep` ms (default 1). 7. `EndScene`: `g_matSystem->EndFrame()`. 8. Pending fullscreen toggle, `g_onEndFrame`. +The ImGui renderer backend (`imgui_impl_matsystem.cpp`) draws through material system dynamic meshes, +which hold up to 32767 vertices and indices. It sets `ImGuiBackendFlags_RendererHasVtxOffset`, so +ImGui can put more than 65535 vertices into one draw list with 16-bit indices, and splits draw +commands by triangles into chunks that are packed into dynamic meshes. User callbacks draw the +pending chunks first. + `GetFrameTime()` returns the current frame time; `GetWindowSize()`, `GetDefaultFont()`, `GetWindowHandle()`. diff --git a/shared/imgui_backend/imgui_impl_matsystem.cpp b/shared/imgui_backend/imgui_impl_matsystem.cpp index 67efeb80f..8b3db1acb 100644 --- a/shared/imgui_backend/imgui_impl_matsystem.cpp +++ b/shared/imgui_backend/imgui_impl_matsystem.cpp @@ -34,24 +34,34 @@ static void ImGui_ImplMatSystem_SetupRenderState(ImDrawData* draw_data, IGPURend rendPassRecorder->SetIndexBufferView(drawCmd.instanceInfo.indexBuffer, drawCmd.instanceInfo.indexFormat); } -// Render function. -void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRecorder* rendPassRecorder) +// Dynamic mesh capacity is lower than a draw list can have (MAX_DYNAMIC_VERTICES and MAX_DYNAMIC_INDICES +// in DynamicMesh.cpp), draw commands are split by triangles into chunks and chunks are packed into meshes +static constexpr int s_maxBatchVertices = 32000; +static constexpr int s_maxBatchIndices = 31998; // whole triangles + +// part of a draw command that fits into one dynamic mesh +struct ImGui_ImplMatSystem_DrawChunk { - // Avoid rendering when minimized - if (draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f) - return; + const ImDrawCmd* cmd; + int firstIndex; // in the draw list index buffer + int numIndices; + int minVertex; // referenced range of the draw list vertex buffer + int maxVertex; +}; - // Copy vertices to matsystem mesh - ImVec2 clip_off = draw_data->DisplayPos; - for (int n = 0; n < draw_data->CmdListsCount; n++) - { - const ImDrawList* cmd_list = draw_data->CmdLists[n]; +static void ImGui_ImplMatSystem_DrawChunks(ImDrawData* draw_data, IGPURenderPassRecorder* rendPassRecorder, const ImDrawList* cmd_list, ArrayCRef chunks, int numVertices, int numIndices, RenderDrawCmd& drawCmd) +{ + if (chunks.isEmpty()) + return; - IDynamicMeshPtr dynMesh = g_matSystem->GetDynamicMesh(max(cmd_list->VtxBuffer.Size, cmd_list->IdxBuffer.Size)); - CMeshBuilder mb(dynMesh); - mb.Begin(PRIM_TRIANGLES); + IDynamicMeshPtr dynMesh = g_matSystem->GetDynamicMesh(max(numVertices, numIndices)); + CMeshBuilder mb(dynMesh); + mb.Begin(PRIM_TRIANGLES); - for (int i = 0; i < cmd_list->VtxBuffer.Size; i++) + // vertices of each chunk go one after another + for (const ImGui_ImplMatSystem_DrawChunk& chunk : chunks) + { + for (int i = chunk.minVertex; i <= chunk.maxVertex; i++) { const ImDrawVert& vtx_src = cmd_list->VtxBuffer.Data[i]; MColor color(vtx_src.col); @@ -61,53 +71,152 @@ void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRec mb.Color4fv(color); mb.AdvanceVertex(); } + } + // indices are rebased to the chunk vertices + int baseVertex = 0; + for (const ImGui_ImplMatSystem_DrawChunk& chunk : chunks) + { ImDrawIdx* idx_dst; void* vtx_dst; - if (dynMesh->AllocateGeom(0, cmd_list->IdxBuffer.Size, &vtx_dst, &idx_dst, false) != -1) + if (dynMesh->AllocateGeom(0, chunk.numIndices, &vtx_dst, &idx_dst, false) != -1) { - memcpy(idx_dst, cmd_list->IdxBuffer.Data, cmd_list->IdxBuffer.Size * sizeof(ImDrawIdx)); + const ImDrawIdx* idx_src = cmd_list->IdxBuffer.Data + chunk.firstIndex; + const int indexOffset = chunk.cmd->VtxOffset - chunk.minVertex + baseVertex; + for (int i = 0; i < chunk.numIndices; i++) + idx_dst[i] = static_cast(idx_src[i] + indexOffset); } + baseVertex += chunk.maxVertex - chunk.minVertex + 1; + } + + if (!mb.End(drawCmd)) + return; + + const ImVec2 clip_off = draw_data->DisplayPos; + int firstIndex = 0; + for (const ImGui_ImplMatSystem_DrawChunk& chunk : chunks) + { + const ImDrawCmd* pcmd = chunk.cmd; + const int chunkFirstIndex = firstIndex; + firstIndex += chunk.numIndices; - RenderDrawCmd drawCmd; - mb.End(drawCmd); + // Project scissor/clipping rectangles into framebuffer space + ImVec2 clip_min(pcmd->ClipRect.x - clip_off.x, pcmd->ClipRect.y - clip_off.y); + ImVec2 clip_max(pcmd->ClipRect.z - clip_off.x, pcmd->ClipRect.w - clip_off.y); + if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y) + continue; + + // Apply Scissor/clipping rectangle, Bind texture, Draw + + MatSysDefaultRenderPass defaultRenderPass; + defaultRenderPass.blendMode = SHADER_BLEND_TRANSLUCENT; + defaultRenderPass.texture = ITexturePtr((ITexture*)pcmd->GetTexID()); + + g_matSystem->SetupMaterialPipeline(g_matSystem->GetDefaultMaterial(), nullptr, drawCmd.batchInfo.primTopology, drawCmd.instanceInfo.instFormat, RenderPassContext(rendPassRecorder, &defaultRenderPass)); + + rendPassRecorder->SetScissorRectangle(IAARectangle((int)clip_min.x, (int)clip_min.y, (int)clip_max.x, (int)clip_max.y)); + rendPassRecorder->SetVertexBufferView(0, drawCmd.instanceInfo.vertexBuffers[0]); + rendPassRecorder->SetIndexBufferView(drawCmd.instanceInfo.indexBuffer, INDEXFMT_UINT16); + rendPassRecorder->DrawIndexed(chunk.numIndices, chunkFirstIndex, 1); + } +} + +// Render function. +void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRecorder* rendPassRecorder) +{ + // Avoid rendering when minimized + if (draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f) + return; + + Array chunks(PP_SL); + RenderDrawCmd drawCmd; + + for (int n = 0; n < draw_data->CmdListsCount; n++) + { + const ImDrawList* cmd_list = draw_data->CmdLists[n]; + + // chunks waiting for a mesh + int batchVertices = 0; + int batchIndices = 0; + chunks.clear(); + + auto flushChunks = [&]() { + ImGui_ImplMatSystem_DrawChunks(draw_data, rendPassRecorder, cmd_list, chunks, batchVertices, batchIndices, drawCmd); + chunks.clear(); + batchVertices = 0; + batchIndices = 0; + }; + + auto addChunk = [&](const ImGui_ImplMatSystem_DrawChunk& chunk) { + const int chunkVertices = chunk.maxVertex - chunk.minVertex + 1; + if (batchVertices + chunkVertices > s_maxBatchVertices || batchIndices + chunk.numIndices > s_maxBatchIndices) + flushChunks(); + + chunks.append(chunk); + batchVertices += chunkVertices; + batchIndices += chunk.numIndices; + }; - // render command buffers now for (int cmd_i = 0; cmd_i < cmd_list->CmdBuffer.Size; cmd_i++) { const ImDrawCmd* pcmd = &cmd_list->CmdBuffer[cmd_i]; if (pcmd->UserCallback != nullptr) { + // commands before the callback are drawn first + flushChunks(); + // User callback, registered via ImDrawList::AddCallback() // (ImDrawCallback_ResetRenderState is a special callback value used by the user to request the renderer to reset render state.) if (pcmd->UserCallback == ImDrawCallback_ResetRenderState) - ImGui_ImplMatSystem_SetupRenderState(draw_data, rendPassRecorder, drawCmd); + { + if (drawCmd.instanceInfo.vertexBuffers[0].buffer) + ImGui_ImplMatSystem_SetupRenderState(draw_data, rendPassRecorder, drawCmd); + } else pcmd->UserCallback(cmd_list, pcmd); - } - else - { - // Project scissor/clipping rectangles into framebuffer space - ImVec2 clip_min(pcmd->ClipRect.x - clip_off.x, pcmd->ClipRect.y - clip_off.y); - ImVec2 clip_max(pcmd->ClipRect.z - clip_off.x, pcmd->ClipRect.w - clip_off.y); - if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y) - continue; - // Apply Scissor/clipping rectangle, Bind texture, Draw - - MatSysDefaultRenderPass defaultRenderPass; - defaultRenderPass.blendMode = SHADER_BLEND_TRANSLUCENT; - defaultRenderPass.texture = ITexturePtr((ITexture*)pcmd->GetTexID()); + continue; + } - g_matSystem->SetupMaterialPipeline(g_matSystem->GetDefaultMaterial(), nullptr, drawCmd.batchInfo.primTopology, drawCmd.instanceInfo.instFormat, RenderPassContext(rendPassRecorder, &defaultRenderPass)); + // split the command by triangles into chunks that fit into one mesh + ImGui_ImplMatSystem_DrawChunk chunk; + chunk.cmd = pcmd; + chunk.firstIndex = pcmd->IdxOffset; + chunk.numIndices = 0; + chunk.minVertex = INT_MAX; + chunk.maxVertex = -1; - rendPassRecorder->SetScissorRectangle(IAARectangle((int)clip_min.x, (int)clip_min.y, (int)clip_max.x, (int)clip_max.y)); - rendPassRecorder->SetVertexBufferView(0, drawCmd.instanceInfo.vertexBuffers[0]); - rendPassRecorder->SetIndexBufferView(drawCmd.instanceInfo.indexBuffer, INDEXFMT_UINT16); - rendPassRecorder->DrawIndexed(pcmd->ElemCount, pcmd->IdxOffset, 1); + const ImDrawIdx* indices = cmd_list->IdxBuffer.Data; + for (int i = pcmd->IdxOffset; i + 2 < (int)(pcmd->IdxOffset + pcmd->ElemCount); i += 3) + { + const int triMin = min(min(indices[i], indices[i + 1]), indices[i + 2]) + pcmd->VtxOffset; + const int triMax = max(max(indices[i], indices[i + 1]), indices[i + 2]) + pcmd->VtxOffset; + + const int minVertex = min(chunk.minVertex, triMin); + const int maxVertex = max(chunk.maxVertex, triMax); + if (chunk.numIndices && (maxVertex - minVertex + 1 > s_maxBatchVertices || chunk.numIndices + 3 > s_maxBatchIndices)) + { + addChunk(chunk); + + chunk.firstIndex = i; + chunk.numIndices = 0; + chunk.minVertex = triMin; + chunk.maxVertex = triMax; + } + else + { + chunk.minVertex = minVertex; + chunk.maxVertex = maxVertex; + } + chunk.numIndices += 3; } + + if (chunk.numIndices) + addChunk(chunk); } + + flushChunks(); } } @@ -120,7 +229,7 @@ bool ImGui_ImplMatSystem_Init() ImGui_ImplMatSystem_Data* bd = IM_NEW(ImGui_ImplMatSystem_Data)(); io.BackendRendererUserData = (void*)bd; io.BackendRendererName = "eqMatSystem"; - //io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; // We can honor the ImDrawCmd::VtxOffset field, allowing for large meshes. + io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; // We can honor the ImDrawCmd::VtxOffset field, allowing for large meshes. return true; } From ae3350d5221dfc0671f399ce6dbc1a9fa7689be5 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 01:29:48 +0500 Subject: [PATCH 14/20] - nvrhi: update submodule pointer (D3D12 sampler descriptor tables shared between binding sets) - docs: describe D3D12 sampler heap usage by eqNVRHI binding sets in public_rendering_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/public_rendering_overview.md | 9 +++++++++ src_dependency/nvrhi/NVRHI | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/public_rendering_overview.md b/docs/public_rendering_overview.md index 8fc5a9ee7..edfd53cd1 100644 --- a/docs/public_rendering_overview.md +++ b/docs/public_rendering_overview.md @@ -12,6 +12,15 @@ render passes, pipelines) will be described in separate documents. | `eqNVRHI`, `eqWGPURHI`, `eqNullRHI` | shared libraries | RHI backends (D3D12/Vulkan, WebGPU, null) implementing `IShaderAPI`; selected by the `MaterialSystem.Renderer` list in `E2.CONFIG` | | `BaseShader` | static library | `CBaseShader` base class for writing material shaders (`public/materialsystem1/BaseShader.*`) | +`eqNVRHI` creates an NVRHI binding set for every bind group and pipeline pair; a bind group with a +binding layout gets a binding set per pipeline id (`nvrhiFillBindingSets` in `NVRHIStates.cpp`). +On D3D12 every binding set needs a sampler descriptor table, and the shader-visible sampler heap is +limited to 2048 descriptors (`D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE`). The engine's NVRHI fork +(`src_dependency/nvrhi/NVRHI`, branch `insbyte-changes`) therefore shares sampler tables between +binding sets that have the same sampler descriptors (`DeviceResources::acquireSamplerTable`), so heap +usage depends on the number of distinct sampler combinations, not on the number of materials and +pipeline permutations. + Public headers: | Header | Contents | diff --git a/src_dependency/nvrhi/NVRHI b/src_dependency/nvrhi/NVRHI index 66a0332ce..d54097996 160000 --- a/src_dependency/nvrhi/NVRHI +++ b/src_dependency/nvrhi/NVRHI @@ -1 +1 @@ -Subproject commit 66a0332cef59a3075305f6dc9d46170724c2ab93 +Subproject commit d5409799680bbe6eb717203966b0bb9f674be444 From e0023c1a7125e58be98bac3e37f775aec5bfb00f Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 01:39:04 +0500 Subject: [PATCH 15/20] - nvrhi: add unique binding layout storage and cache binding sets using their key if possible --- .../Renderers/NVRHI/NVRHIRenderAPI.cpp | 49 +++++++++++++++++++ .../Renderers/NVRHI/NVRHIRenderAPI.h | 5 ++ .../Renderers/NVRHI/NVRHIStates.cpp | 22 +++++++-- docs/public_rendering_overview.md | 8 ++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.cpp b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.cpp index 78e8b45a7..89c723a82 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.cpp +++ b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.cpp @@ -109,6 +109,7 @@ void CNVRHIRenderAPI::Shutdown() m_rhiCommandLists.clear(true); m_rhiFreeCommandLists.clear(true); m_rhiSamplers.clear(true); + m_rhiBindingLayouts.clear(true); m_rhiDevice = nullptr; } @@ -796,6 +797,54 @@ nvrhi::SamplerHandle CNVRHIRenderAPI::GetRHISampler(const SamplerStateParams& sa return rhiSampler; } +static bool nvrhiBindingLayoutDescEqual(const nvrhi::BindingLayoutDesc& a, const nvrhi::BindingLayoutDesc& b) +{ + return a.visibility == b.visibility + && a.registerSpace == b.registerSpace + && a.registerSpaceIsDescriptorSet == b.registerSpaceIsDescriptorSet + && a.bindings == b.bindings + && a.bindingOffsets.shaderResource == b.bindingOffsets.shaderResource + && a.bindingOffsets.sampler == b.bindingOffsets.sampler + && a.bindingOffsets.constantBuffer == b.bindingOffsets.constantBuffer + && a.bindingOffsets.unorderedAccess == b.bindingOffsets.unorderedAccess; +} + +nvrhi::BindingLayoutHandle CNVRHIRenderAPI::GetRHIBindingLayout(const nvrhi::BindingLayoutDesc& rhiLayoutDesc) +{ + size_t hash = 0; + nvrhi::hash_combine(hash, static_cast(rhiLayoutDesc.visibility)); + nvrhi::hash_combine(hash, rhiLayoutDesc.registerSpace); + nvrhi::hash_combine(hash, rhiLayoutDesc.registerSpaceIsDescriptorSet); + nvrhi::hash_combine(hash, rhiLayoutDesc.bindingOffsets.shaderResource); + nvrhi::hash_combine(hash, rhiLayoutDesc.bindingOffsets.sampler); + nvrhi::hash_combine(hash, rhiLayoutDesc.bindingOffsets.constantBuffer); + nvrhi::hash_combine(hash, rhiLayoutDesc.bindingOffsets.unorderedAccess); + for (const nvrhi::BindingLayoutItem& item : rhiLayoutDesc.bindings) + { + nvrhi::hash_combine(hash, item.slot); + nvrhi::hash_combine(hash, static_cast(item.type)); + nvrhi::hash_combine(hash, static_cast(item.size)); + } + const uint layoutId = static_cast(hash ^ (hash >> 32)); + + CScopedMutex m(m_rhiBindingLayoutsMutex); + auto it = m_rhiBindingLayouts.find(layoutId); + if (it) + { + if (nvrhiBindingLayoutDescEqual(*(*it)->getDesc(), rhiLayoutDesc)) + return *it; + + // hash collision, layout is not shared + return m_rhiDevice->createBindingLayout(rhiLayoutDesc); + } + + nvrhi::BindingLayoutHandle rhiLayout = m_rhiDevice->createBindingLayout(rhiLayoutDesc); + if (rhiLayout) + m_rhiBindingLayouts.insert(layoutId, rhiLayout); + + return rhiLayout; +} + IGPURenderPipelinePtr CNVRHIRenderAPI::CreateRenderPipeline(const RenderPipelineDesc& pipelineDesc, const IGPUBindingLayout* bindingLayout) const { PROF_EVENT("CreateRenderPipeline"); diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.h b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.h index 69267a488..f1c73e0e5 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.h +++ b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderAPI.h @@ -101,6 +101,7 @@ class CNVRHIRenderAPI : public ShaderAPI_Base nvrhi::DeviceHandle GetNVRHIDevice() const { return m_rhiDevice; } nvrhi::SamplerHandle GetRHISampler(const SamplerStateParams& samplerStateParams); + nvrhi::BindingLayoutHandle GetRHIBindingLayout(const nvrhi::BindingLayoutDesc& rhiLayoutDesc); int AcquireRHITransientTextureHeap(); void ReleaseRHITransientTextureHeap(int heapIdx); @@ -132,6 +133,10 @@ class CNVRHIRenderAPI : public ShaderAPI_Base Map m_rhiSamplers{ PP_SL }; + // pipelines with equal binding layouts share layout objects, so they can share binding sets too + Map m_rhiBindingLayouts{ PP_SL }; + CEqMutex m_rhiBindingLayoutsMutex; + Map m_shaderCache{ PP_SL }; nvrhi::DeviceHandle m_rhiDevice{ nullptr }; ENVRHIBackendType m_rhiBackendType; diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHIStates.cpp b/core/materialsystem1/Renderers/NVRHI/NVRHIStates.cpp index 05975f4bc..9e1d2b2ee 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHIStates.cpp +++ b/core/materialsystem1/Renderers/NVRHI/NVRHIStates.cpp @@ -368,11 +368,10 @@ void nvrhiCreateBindingLayouts(const ShaderInfo& shaderInfo, const CNVRHIBinding } } - nvrhi::IDevice* rhiDevice = CNVRHIRenderAPI::Instance.GetNVRHIDevice(); for (nvrhi::BindingLayoutDesc& rhiDesc : rhiBindingLayoutDescList) { rhiDesc.setVisibility(rhiShaderType); - rhiBindingLayouts.append(rhiDevice->createBindingLayout(rhiDesc)); + rhiBindingLayouts.append(CNVRHIRenderAPI::Instance.GetRHIBindingLayout(rhiDesc)); } } } @@ -392,12 +391,27 @@ void nvrhiFillBindingSets(const ShaderInfo& shaderInfo, ArrayCRef shaderMod if (!bindingSetIt) { const BindGroupDesc& bindGroupDesc = bindGroup->m_bindGroupDesc; + nvrhi::IBindingLayout* rhiBindingLayout = rhiBindingLayouts[bindGroupDesc.groupIdx]; // we need to create binding set for this shader using provided layout auto rhiBindingSetDesc = nvrhi::BindingSetDesc(); bindGroup->m_bindingLayout->FillBindingSetDescByLayoutMap(bindGroupDesc, shaderInfo, shaderModuleIdxs, rhiBindingSetDesc); - - nvrhi::BindingSetHandle rhiBindSet = nvrhiDevice->createBindingSet(rhiBindingSetDesc, rhiBindingLayouts[bindGroupDesc.groupIdx]); + + // pipelines with the same layout and bindings (e.g. instanced and non-instanced permutations) share the binding set + nvrhi::BindingSetHandle rhiBindSet; + for (auto it = bindGroup->m_rhiBindingSets.begin(); !it.atEnd(); ++it) + { + nvrhi::IBindingSet* rhiExistingSet = *it; + if (rhiExistingSet->getLayout() == rhiBindingLayout && *rhiExistingSet->getDesc() == rhiBindingSetDesc) + { + rhiBindSet = rhiExistingSet; + break; + } + } + + if (!rhiBindSet) + rhiBindSet = nvrhiDevice->createBindingSet(rhiBindingSetDesc, rhiBindingLayout); + if (!rhiBindSet) { ASSERT_FAIL("Failed to create binding set for shared bind group %s\n", bindGroupDesc.name.ToCString()); diff --git a/docs/public_rendering_overview.md b/docs/public_rendering_overview.md index edfd53cd1..4f9ca6097 100644 --- a/docs/public_rendering_overview.md +++ b/docs/public_rendering_overview.md @@ -12,8 +12,12 @@ render passes, pipelines) will be described in separate documents. | `eqNVRHI`, `eqWGPURHI`, `eqNullRHI` | shared libraries | RHI backends (D3D12/Vulkan, WebGPU, null) implementing `IShaderAPI`; selected by the `MaterialSystem.Renderer` list in `E2.CONFIG` | | `BaseShader` | static library | `CBaseShader` base class for writing material shaders (`public/materialsystem1/BaseShader.*`) | -`eqNVRHI` creates an NVRHI binding set for every bind group and pipeline pair; a bind group with a -binding layout gets a binding set per pipeline id (`nvrhiFillBindingSets` in `NVRHIStates.cpp`). +`eqNVRHI` creates NVRHI binding sets for bind groups on demand; a bind group with a binding layout +looks up its binding set by pipeline id (`nvrhiFillBindingSets` in `NVRHIStates.cpp`). Binding +layouts are cached by their description (`CNVRHIRenderAPI::GetRHIBindingLayout`), so pipelines with +equal layouts get the same layout objects, and a bind group reuses its binding set for every +pipeline id whose layout and resulting bindings are equal (for example, instanced and non-instanced +permutations of one shader). On D3D12 every binding set needs a sampler descriptor table, and the shader-visible sampler heap is limited to 2048 descriptors (`D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE`). The engine's NVRHI fork (`src_dependency/nvrhi/NVRHI`, branch `insbyte-changes`) therefore shares sampler tables between From 3359224038dc1b2d2e3151810744dd36170551e2 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 02:44:33 +0500 Subject: [PATCH 16/20] - deps: remove wxWidgets premake project, premake module and downloads in dev prepare scripts - deps: remove 7Zip4PowerShell (used only to unpack wxWidgets), drop it from msbuild workflow - docs: remove wxWidgets from building, architecture and readme Co-Authored-By: Claude Opus 5.5 --- .github/workflows/msbuild.yml | 5 - .gitignore | 2 - README.md | 2 +- docs/architecture.md | 1 - docs/building.md | 10 +- premake5.lua | 1 - src_dependency/premake5.lua | 1 - src_dependency/wxWidgets/premake5.lua | 28 ---- utils/.premake_modules/wxwidgets.lua | 231 -------------------------- utils/cross_windows_dev_prepare.sh | 9 - utils/linux_dev_prepare.sh | 2 +- utils/win_install_7zip4powershell.ps1 | 4 - utils/windows_dev_prepare.ps1 | 9 - 13 files changed, 5 insertions(+), 300 deletions(-) delete mode 100644 src_dependency/wxWidgets/premake5.lua delete mode 100644 utils/.premake_modules/wxwidgets.lua delete mode 100644 utils/win_install_7zip4powershell.ps1 diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index 11ad799ad..d47a5885c 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -51,11 +51,6 @@ jobs: token: ${{ secrets.ACTIONSPAT }} submodules: recursive - - name: PowerShell Module Cache - uses: potatoqualitee/psmodulecache@v6.2.1 - with: - modules-to-cache: 7Zip4PowerShell - - name: Run PS commands shell: pwsh run: .\utils\windows_dev_prepare.ps1 diff --git a/.gitignore b/.gitignore index b90c04b59..e52c9e912 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,8 @@ android-project/.idea build/* samples/*/build -src_dependency/wxWidgets/* src_dependency/openal-soft/* src_dependency/SDL2/* -!src_dependency/wxWidgets/premake5.lua !src_dependency/openal-soft/premake5.lua !src_dependency/SDL2/premake5.lua src_dependency/slang/* diff --git a/README.md b/README.md index f24a70043..858bbaf39 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ While it demonstrates some best practices used in AA and AAA game development, s - Build All #### Linux -- Install packages: `build-essential SDL2-devel openal-soft-devel libXxf86vm-devel wxBase3-devel wxGTK3-devel` +- Install packages: `build-essential SDL2-devel openal-soft-devel libXxf86vm-devel` - Run `utils/linux_dev_prepare.sh` - Run `utils/premake5 --file=samples\GPUDriven\premake5.lua gmake2 && utils/premake5 --file=samples\GPUDriven\premake5.lua vscode` in repo root - Open VS Code and open `samples\GPUDriven\build` folder, install recommended C++ extensions diff --git a/docs/architecture.md b/docs/architecture.md index 6feb59b89..0c3894723 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,6 @@ loaded at the top of `premake5.lua`: | `unitybuild` | `unitybuild`, `maxfilesinunity`, `excludefromunity` — MSVC unity build support | | `androidndk` | `androidndk` action for Android builds | | `vscode` | `vscode` action and `vscode_*` launch settings | -| `wxwidgets` | wxWidgets integration for GUI tools | ### usage / uses diff --git a/docs/building.md b/docs/building.md index 69e1a5e2f..444e2bcba 100644 --- a/docs/building.md +++ b/docs/building.md @@ -18,8 +18,6 @@ releases and works differently from them. - Windows SDK 10.0.26100 or newer (required by Tracy). The SDK version can be set with the `WINSDK_VER` environment variable (for example `10.0.26100.0`); without it the latest installed SDK is used. -- PowerShell module `7Zip4PowerShell`, used to unpack dependency archives. Install it once with - `utils/win_install_7zip4powershell.ps1`. ### Dependencies @@ -27,13 +25,11 @@ releases and works differently from them. powershell -ExecutionPolicy Bypass -File utils\windows_dev_prepare.ps1 ``` -The script downloads SDL2, OpenAL-soft, wxWidgets and Slang and unpacks them into `src_dependency/`. +The script downloads SDL2, OpenAL-soft and Slang and unpacks them into `src_dependency/`. Run it once after cloning and again after the dependency versions in the script change. -wxWidgets is not used by the engine projects, but is required by the game project that includes -the engine (`src_game`). -`utils/cross_windows_dev_prepare.sh` does the same from a Linux shell (needs `wget`, `unzip` and -`7z`), for example to prepare a Windows checkout from WSL. +`utils/cross_windows_dev_prepare.sh` does the same from a Linux shell (needs `wget` and `unzip`), for +example to prepare a Windows checkout from WSL. ### Generating projects diff --git a/premake5.lua b/premake5.lua index 3b8dd34f6..a547d53b2 100644 --- a/premake5.lua +++ b/premake5.lua @@ -5,7 +5,6 @@ require ".premake_modules/usage" require ".premake_modules/properties" require ".premake_modules/androidndk" require ".premake_modules/unitybuild" -require ".premake_modules/wxwidgets" require ".premake_modules/vscode" IS_ANDROID = (_ACTION == "androidndk") diff --git a/src_dependency/premake5.lua b/src_dependency/premake5.lua index ca0873e5e..5534b4bd5 100644 --- a/src_dependency/premake5.lua +++ b/src_dependency/premake5.lua @@ -8,7 +8,6 @@ if IS_ANDROID then else include(DependencyPath.openal) include(DependencyPath.libsdl) - include "wxWidgets" end if ENABLE_TESTS then diff --git a/src_dependency/wxWidgets/premake5.lua b/src_dependency/wxWidgets/premake5.lua deleted file mode 100644 index cd8e8263f..000000000 --- a/src_dependency/wxWidgets/premake5.lua +++ /dev/null @@ -1,28 +0,0 @@ -usage "wxWidgets" - filter "system:Linux" - wx_config { - Root=WX_DIR, - Debug="no", - Static="no", - Unicode="no", - Version="3.0", - Libs="core,aui,gl" - } - - filter "system:Windows" - includedirs { - "./include", - "./include/msvc" - } - - defines { "WXUSINGDLL", "wxMSVC_VERSION_AUTO", "wxMSVC_VERSION_ABI_COMPAT" } - - filter "platforms:x64" - libdirs { - "./lib/vc14x_x64_dll" - } - - filter "platforms:x86" - libdirs { - "./lib/vc14x_dll" - } diff --git a/utils/.premake_modules/wxwidgets.lua b/utils/.premake_modules/wxwidgets.lua deleted file mode 100644 index 3a8372650..000000000 --- a/utils/.premake_modules/wxwidgets.lua +++ /dev/null @@ -1,231 +0,0 @@ --- NOTE: --- This script was taken from: https://wiki.wxwidgets.org/Premake4 --- And modified by Miss to fix some issues and work with Premake5 --- --- Use like: --- flags "WinMain" --- configuration "Debug" --- wx_config { Root=WX_DIR, Debug="yes", Static="yes", Unicode="yes", Version="3.1", Libs="core,adv,gl,aui,propgrid" } --- configuration "Release" --- wx_config { Root=WX_DIR, Debug="np", Static="yes", Unicode="yes", Version="3.1", Libs="core,adv,gl,aui,propgrid" } - --- use_wxwidgets.lua --- wxWidgets configuration file for premake4 --- --- authors: laurent.humbertclaude@gmail.com --- v.krishnakumar@gmail.com - --- optional root folder of wxwidgets installation folder --- - -newoption { - trigger = "wx_root", - value = "PATH", - description = "Path to wxwidgets root folder, by default, WXWIN envvar will be used or wx-config found in path on POSIX" -} - -function shellout(str) - if USE_RUNTIME then - return { "`" .. str .. "`" } - end - - local out_links = {} - local out_includes = {} - - ret = {} - local cmd_result = os.outputof(str) or "" - for i in (cmd_result:match'^%s*(.*%S)' or ''):gmatch('%S+') do - if string.sub(i, 1, 2) == "-l" then - table.insert(out_links, string.sub(i, 3)) - elseif string.sub(i, 1, 2) == "-I" then - table.insert(out_includes, string.sub(i, 3)) - else - table.insert(ret, i) - end - end - - links(out_links) - includedirs(out_includes) - - return ret -end - --- The wx_config the parameters are. --- Root : path to wx root folder. Can be left empty if WXWIN is defined --- or if wx-config is accessible. --- Debug : "yes" use debug version of wxwidgets. Default to "no" --- Version : one of '2.4', '2.5', '2.6', '2.7', '2.8', '2.9'. Default to '2.9' --- Static : indicates how wx is to be linked. Values are --- either "yes" for static linking or "no" for shared linking, Default to "no" --- Unicode : use "yes" for unicode or "no" for ansi version. --- ansi version only available up to 2.8 --- Default to "yes" --- Universal : use universal configuration. Default to "no" --- Libs : a list of wx libraries that you want to link with. --- eg: "aui,media,html" --- Default to "richtext,aui,xrc,qa,html,adv,core,xml,net"; base is implicit --- WindowsCompiler : compiler used to compile windows libraries ( "vc" or "gcc" ) - -function wx_config(options) - - local wrongParam = false - local allowedWxOptions = {"Root", "Debug", "Host", "Version", "Static", "Unicode", "Universal", "Libs", "WindowsCompiler" } - for option in pairs(options) do - if not table.contains(allowedWxOptions, option) then - print ("unrecognized option '"..option.. "'") - wrongParam = true - end - end - if wrongParam then print("valid options are : '" .. table.concat(allowedWxOptions, "', '").."'") end - - wx_config_Private( options.Root or "", - options.Debug or "", - options.Host or "", - options.Version or "2.9", - options.Static or "", - options.Unicode or "yes", - options.Universal or "", - options.Libs or "richtext,aui,xrc,qa,html,adv,core,xml,net", -- base is implicit, std not valid - options.WindowsCompiler or "vc" - ) -end - -function wx_config_Private(wxRoot, wxDebug, wxHost, wxVersion, wxStatic, wxUnicode, wxUniversal, wxLibs, wxWindowsCompiler) - -- some options are not allowed for newer version of wxWidgets - if wxVersion > "2.8" then -- alphabetical comparison may fail... - -- wxDebug = "" -- 2.9 still make debug libraries - wxUnicode = "yes" - end - - --wx_root=PATH override wxRoot parameter - if _OPTIONS and _OPTIONS["wx_root"] then - print ("seen option '--wx_root=" .. _OPTIONS["wx_root"] .. "' overriding default root = '" .. wxRoot .. "'") - wxRoot = _OPTIONS["wx_root"] - end - -- the environment variable WXWIN override both wxRoot parameter and --wx_root option - if os.getenv('WXWIN') then wxRoot = os.getenv('WXWIN') end - - if wxUnicode == "yes" then - defines { "_UNICODE" } - characterset "Unicode" - end - - if wxDebug == "yes" then defines { "__WXDEBUG__" } - elseif wxDebug == "no" then optimize("On") end - - if wxStatic == "yes" then - --flags { "StaticRuntime" } - if os.target() == "windows" then - links { "ComCtl32", "RpcRT4", "WinMM", "AdvAPI32", "WSock32" } - end - else - defines { "WXUSINGDLL" } - end - - - -- function to compensate lack of wx-config program on windows - -- but wait, look at http://sites.google.com/site/wxconfig/ for one ! - function wx_config_for_windows(wxWindowsCompiler) - local wxBuildType = "" -- buildtype is one of "", "u", "d" or "ud" - local wxDebugSuffix = "" -- debug buildsuffix is for support libraries only - if wxUnicode ~= "no" then wxBuildType = wxBuildType .. "u" end - if wxDebug == "yes" then - wxBuildType = wxBuildType .. "d" - wxDebugSuffix = "d" - end - - local wxLibPath = path.join(wxRoot, "lib") - wxLibPath = path.join(wxLibPath, wxWindowsCompiler .. "_" .. iif(wxStatic == 'yes', 'lib', 'dll')) - -- common defines - defines{ "__WXMSW__" } - - -- common include path - includedirs { - path.join(wxRoot, "include"), - path.join(wxLibPath, "msw" .. wxBuildType) -- something like "%WXWIN%\lib\vc_lib\mswud" to find "wx/setup.h" - } - - -- common library path - libdirs { wxLibPath } - - -- add the libs - libVersion = string.gsub(wxVersion, '%.', '') -- remove dot from version - links { "wxbase"..libVersion..wxBuildType } -- base lib - for i, lib in ipairs(string.explode(wxLibs, ",")) do - local libPrefix = 'wxmsw' - if lib == "xml" or lib == "net" or lib == "odbc" then - libPrefix = 'wxbase' - end - links { libPrefix..libVersion..wxBuildType..'_'..lib} - end - -- link with support libraries - for i, lib in ipairs({"wxjpeg", "wxpng", "wxzlib", "wxtiff", "wxexpat"}) do - links { lib..wxDebugSuffix } - end - links { "wxregex" .. wxBuildType } - end - - -- use wx-config to figure out build parameters - function wx_config_for_posix() - local configCmd = "wx-config" -- this is the wx-config command line - if wxRoot ~= "" and os.target() ~= 'macosx' then configCmd = path.join(wxRoot, "bin/wx-config") end - - local function checkYesNo(value, option) - if value == "" then return "" end - if value == "yes" or value == "no" then return " --"..option.."="..value end - error("wx"..option..' can only be "yes", "no" or empty' ) - end - - configCmd = configCmd .. checkYesNo(wxDebug, "debug") - configCmd = configCmd .. checkYesNo(wxStatic, "static") - configCmd = configCmd .. checkYesNo(wxUnicode, "unicode") - configCmd = configCmd .. checkYesNo(wxUniversal, "universal") - if wxHost ~= "" then configCmd = configCmd .. " --host=" .. wxHost end - if wxVersion ~= "" then configCmd = configCmd .. " --version=" .. wxVersion end - - buildFlags = shellout(configCmd .." --cxxflags") - libFlags = shellout(configCmd .." --libs " .. wxLibs) - - if os.target() == 'macosx' then - -- on osx we need to concatenate the "-framework" and "Cocoa" parts, since they are split and it confuses GENie - for i, v in ipairs(libFlags) do - if v == '-framework' then - libFlags[i] = '-framework ' .. libFlags[i + 1] - libFlags[i + 1] = '' - end - end - end - - -- set the parameters to the curent configuration - buildoptions(buildFlags) - linkoptions(libFlags) - end - --- BUG: here, using any configuration() function will reset the current filter --- and apply configuration to all project configuration... --- see http://industriousone.com/post/add-way-refine-configuration-filter --- and http://sourceforge.net/tracker/?func=detail&aid=2936443&group_id=71616&atid=531881 ---~ configuration "not windows" ---~ wx_config_for_posix() ---~ configuration "vs*" ---~ wx_config_for_windows("vc") ---~ configuration {"windows", "codeblocks or gmake or codelitle"} ---~ wx_config_for_windows("gcc") - if os.target() ~= "windows" then - wx_config_for_posix() - else - local allowedCompiler = {"vc", "gcc"} - if not table.contains( allowedCompiler, wxWindowsCompiler) then - print( "wrong wxWidgets Compiler specified('"..wxWindowsCompiler.."'), should be one of '".. table.concat(allowedCompiler, "', '").."'" ) - wxWindowsCompiler = "vc" - end ---~ BUG/WISH: I need a function like compiler.get() that return the project/configuration compiler ---~ local wxWindowsCompiler = "vc" ---~ BUG? --cc=compiler standard premake option is not registered in the _OPTIONS array ---~ if _OPTIONS and _OPTIONS["cc"] then ---~ wxWindowsCompiler = _OPTIONS.cc ---~ print("seen option '--cc=" .. _OPTIONS["cc"] .. "' overriding default cc='vc'") ---~ end - wx_config_for_windows(wxWindowsCompiler) - end -end diff --git a/utils/cross_windows_dev_prepare.sh b/utils/cross_windows_dev_prepare.sh index 7da2b997f..33ed8815b 100755 --- a/utils/cross_windows_dev_prepare.sh +++ b/utils/cross_windows_dev_prepare.sh @@ -1,13 +1,10 @@ openal_ver="1.23.1" sdl2_ver="2.30.2" -wx_ver="3.2.1" slang_ver="2025.19.1" windows_openal_url="https://github.com/kcat/openal-soft/releases/download/${openal_ver}/openal-soft-${openal_ver}-bin.zip" windows_sdl2_url="https://github.com/libsdl-org/SDL/releases/download/release-${sdl2_ver}/SDL2-devel-${sdl2_ver}-VC.zip" -windows_wx_hdrs_url="https://github.com/wxWidgets/wxWidgets/releases/download/v${wx_ver}/wxWidgets-${wx_ver}-headers.7z" -windows_wx_libs_url="https://github.com/wxWidgets/wxWidgets/releases/download/v${wx_ver}/wxMSW-${wx_ver}_vc14x_x64_Dev.7z" windows_slang_url="https://github.com/shader-slang/slang/releases/download/v${slang_ver}/slang-${slang_ver}-windows-x86_64.zip" project_folder="$(pwd)" @@ -18,16 +15,12 @@ echo "Downloading dependencies..." # Download required dependencies wget $windows_sdl2_url -O "SDL2.zip" wget $windows_openal_url -O "OPENAL.zip" -wget $windows_wx_hdrs_url -O "WX_HDRS.7z" -wget $windows_wx_libs_url -O "WX_LIBS.7z" wget $windows_slang_url -O "SLANG.zip" echo "Extracting dependencies..." unzip "SDL2.zip" -d $dependency_folder unzip "OPENAL.zip" -d $dependency_folder -7z x "WX_HDRS.7z" -o"${dependency_folder}/wxWidgets" -7z x "WX_LIBS.7z" -o"${dependency_folder}/wxWidgets" unzip "SLANG.zip" -d "${dependency_folder}/slang" # Generate project files @@ -42,8 +35,6 @@ rm -rf $windows_sdl2_dir rm -f "SDL2.zip" rm -f "OPENAL.zip" -rm -f "WX_HDRS.7z" -rm -f "WX_LIBS.7z" rm -f "SLANG.zip" echo "Completed" diff --git a/utils/linux_dev_prepare.sh b/utils/linux_dev_prepare.sh index 66aeb9bbf..334ef54ac 100755 --- a/utils/linux_dev_prepare.sh +++ b/utils/linux_dev_prepare.sh @@ -5,7 +5,7 @@ set -x SLANG_VER="2025.19.1" DAV1D_VER="1.5.4" -# TODO: openal-soft, SDL2, X11, Wayland, wxGTK packages +# TODO: openal-soft, SDL2, X11, Wayland packages wget "https://github.com/shader-slang/slang/releases/download/v${SLANG_VER}/slang-${SLANG_VER}-linux-x86_64.tar.gz" tar -xzf ./slang-${SLANG_VER}-linux-x86_64.tar.gz -C "./src_dependency/slang/" diff --git a/utils/win_install_7zip4powershell.ps1 b/utils/win_install_7zip4powershell.ps1 deleted file mode 100644 index 81ceb69d1..000000000 --- a/utils/win_install_7zip4powershell.ps1 +++ /dev/null @@ -1,4 +0,0 @@ -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -Install-PackageProvider -Name NuGet -Scope CurrentUser -MinimumVersion 2.8.5.201 -Force -Set-PSRepository -Name 'PSGallery' -SourceLocation "https://www.powershellgallery.com/api/v2" -InstallationPolicy Trusted -Install-Module -Name 7Zip4PowerShell -Force \ No newline at end of file diff --git a/utils/windows_dev_prepare.ps1 b/utils/windows_dev_prepare.ps1 index cb69829b1..6d8f7e8bb 100644 --- a/utils/windows_dev_prepare.ps1 +++ b/utils/windows_dev_prepare.ps1 @@ -1,14 +1,11 @@ $openal_ver = '1.23.1' $sdl2_ver = '2.30.2' -$wx_ver = '3.2.1' $slang_ver = '2025.19.1' $windows_openal_url = 'https://github.com/kcat/openal-soft/releases/download/' + $openal_ver + '/openal-soft-' + $openal_ver + '-bin.zip' $windows_sdl2_url = 'https://github.com/libsdl-org/SDL/releases/download/release-' + $sdl2_ver + '/SDL2-devel-' + $sdl2_ver + '-VC.zip' -$windows_wx_hdrs_url = 'https://github.com/wxWidgets/wxWidgets/releases/download/v' +$wx_ver+ '/wxWidgets-' +$wx_ver+ '-headers.7z' -$windows_wx_libs_url = 'https://github.com/wxWidgets/wxWidgets/releases/download/v' +$wx_ver+ '/wxMSW-' +$wx_ver+ '_vc14x_x64_Dev.7z' $windows_slang_url = 'https://github.com/shader-slang/slang/releases/download/v' +$slang_ver+ '/slang-' +$slang_ver+ '-windows-x86_64.zip' $project_folder = '.\\' @@ -17,14 +14,10 @@ $dependency_folder = $project_folder + '\\src_dependency' # Download required dependencies Invoke-WebRequest -Uri $windows_sdl2_url -OutFile SDL2.zip Invoke-WebRequest -Uri $windows_openal_url -OutFile OPENAL.zip -Invoke-WebRequest -Uri $windows_wx_hdrs_url -OutFile WX_HDRS.7z -Invoke-WebRequest -Uri $windows_wx_libs_url -OutFile WX_LIBS.7z Invoke-WebRequest -Uri $windows_slang_url -OutFile SLANG.zip Expand-Archive SDL2.zip -DestinationPath $dependency_folder Expand-Archive OPENAL.zip -DestinationPath $dependency_folder -Expand-7Zip -ArchiveFileName WX_HDRS.7z -TargetPath ($dependency_folder + '\\wxWidgets') -Expand-7Zip -ArchiveFileName WX_LIBS.7z -TargetPath ($dependency_folder + '\\wxWidgets') Expand-Archive SLANG.zip -DestinationPath ($dependency_folder + '\\slang') # Generate project files @@ -39,8 +32,6 @@ Remove-Item $windows_sdl2_dir -Recurse Remove-Item SDL2.zip Remove-Item OPENAL.zip -Remove-Item WX_HDRS.7z -Remove-Item WX_LIBS.7z Remove-Item SLANG.zip Set-Location -Path $project_folder From b9e8b2b1d789416a3db2a9dd67e0eebd62b9175e Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 03:32:15 +0500 Subject: [PATCH 17/20] - matsystem: store shader shared bind groups in the material system pipeline cache instead of shader class statics, add ReleaseSharedBindGroups and GenerateRenderPassVersion - matsystem: async pipeline jobs own their inputs and resolve futures polled by EnsureRenderPipeline, no callbacks into shaders or materials - matsystem: count pipeline cache users (AcquireRenderPipelineCache / ReleaseRenderPipelineCache), drop unused caches in ReleaseUnusedMaterials - matsystem: fix ReleaseUnusedMaterials frame check, flush materials with CMaterial::Flush keeping vars and proxies, reload flushed materials on use - matsystem: free shaders before dropping pipeline cache in ReloadAllMaterials and ReloadShader, skip materials without shader in ReloadShader - docs: describe pipeline futures, shared bind groups, pipeline cache users and unused material release Co-Authored-By: Claude Opus 5.5 --- core/materialsystem1/MaterialSystem.cpp | 132 ++++++++++-- core/materialsystem1/MaterialSystem.h | 6 +- core/materialsystem1/material.cpp | 21 ++ core/materialsystem1/material.h | 3 + docs/public_rendering_overview.md | 7 + docs/rendering_matsystem_shaders_overview.md | 17 +- public/materialsystem1/BaseShader.cpp | 215 ++++++++++++------- public/materialsystem1/BaseShader.h | 27 +-- public/materialsystem1/IMatSysShader.h | 14 +- public/materialsystem1/IMaterialSystem.h | 15 +- 10 files changed, 336 insertions(+), 121 deletions(-) diff --git a/core/materialsystem1/MaterialSystem.cpp b/core/materialsystem1/MaterialSystem.cpp index 421389a3e..281da8a10 100644 --- a/core/materialsystem1/MaterialSystem.cpp +++ b/core/materialsystem1/MaterialSystem.cpp @@ -716,6 +716,7 @@ void CMaterialSystem::ReleaseUnusedMaterials() { CScopedMutex m(s_matSystemMutex); + int flushedCount = 0; for (auto it = m_loadedMaterials.begin(); !it.atEnd(); ++it) { CMaterial* material = (CMaterial*)*it; @@ -724,12 +725,36 @@ void CMaterialSystem::ReleaseUnusedMaterials() if(!CString::CompareCaseIns(material->GetName(), "Default")) continue; - const int framesDiff = (material->m_frameBound - m_frame); + if (material->GetState() != MATERIAL_LOAD_OK) + continue; + + const uint framesDiff = m_frame - material->m_frameBound; + if(framesDiff < (uint)m_config.materialFlushThresh) + continue; - // Flush materials - if(framesDiff >= m_config.materialFlushThresh - 1) - material->Cleanup(false, true); + material->Flush(); + ++flushedCount; } + + // drop pipeline caches (pipelines, layouts, shared bind groups) of shader classes without materials. + // Pending pipeline jobs only resolve their futures, so dropping them is safe. + int droppedCaches = 0; + { + CScopedMutex m(s_matSystemPipelineMutex); + + Array unusedCaches(PP_SL); + for (auto it = m_renderPipelineCache.begin(); !it.atEnd(); ++it) + { + if (it.value().users <= 0) + unusedCaches.append(it.key()); + } + + for (int shaderNameHash : unusedCaches) + m_renderPipelineCache.remove(shaderNameHash); + droppedCaches = unusedCaches.numElem(); + } + + DevMsg(DEVMSG_MATSYSTEM, "ReleaseUnusedMaterials: %d materials flushed, %d pipeline caches dropped\n", flushedCount, droppedCaches); } // Reloads materials @@ -740,9 +765,6 @@ void CMaterialSystem::ReloadAllMaterials() MsgInfo("Reloading all materials and shaders...\n"); Array loadingList(PP_SL); - // drop pipeline cache - m_renderPipelineCache.clear(true); - const int oldFreq = m_shaderAPI->GetProgressiveTextureFrequency(); m_shaderAPI->SetProgressiveTextureFrequency(0); @@ -752,12 +774,24 @@ void CMaterialSystem::ReloadAllMaterials() m_shaderAPI->ReloadShaderPackage(it.key()); } + // free all shaders first, they release pipeline caches + for (auto it = m_loadedMaterials.begin(); !it.atEnd(); ++it) + { + CMaterial* material = static_cast(*it); + material->Cleanup(material->m_loadFromDisk, true); + } + + // drop pipeline cache + { + CScopedMutex m(s_matSystemPipelineMutex); + m_renderPipelineCache.clear(true); + } + for (auto it = m_loadedMaterials.begin(); !it.atEnd(); ++it) { CMaterial* material = static_cast(*it); const bool loadedFromDisk = material->m_loadFromDisk; - material->Cleanup(loadedFromDisk, true); // don't reload materials which are not from disk if(!loadedFromDisk) @@ -805,26 +839,33 @@ void CMaterialSystem::ReloadShader(const char* name) return; } - // clear pipeline cache first - { - CScopedMutex m(s_matSystemPipelineMutex); - m_renderPipelineCache.remove(shaderNameHash); - } - Array loadingList(PP_SL); + Array reloadList(PP_SL); const int oldFreq = m_shaderAPI->GetProgressiveTextureFrequency(); m_shaderAPI->SetProgressiveTextureFrequency(0); - // reload materials which have that shader + // free shaders of materials which have that shader, they release the pipeline cache. + // Materials without shader (not loaded or flushed) will load the new one on use. for (auto it = m_loadedMaterials.begin(); !it.atEnd(); ++it) { CMaterial* material = static_cast(*it); - if (CString::Compare(material->m_shader->GetName(), name) != 0) + if (!material->m_shader || CString::Compare(material->m_shader->GetName(), name) != 0) continue; + material->Cleanup(material->m_loadFromDisk, true); + reloadList.append(material); + } + + // clear pipeline cache + { + CScopedMutex m(s_matSystemPipelineMutex); + m_renderPipelineCache.remove(shaderNameHash); + } + + for (CMaterial* material : reloadList) + { const bool loadedFromDisk = material->m_loadFromDisk; - material->Cleanup(loadedFromDisk, true); // don't reload materials which are not from disk if (!loadedFromDisk) @@ -934,10 +975,47 @@ void CMaterialSystem::RegisterShaderOverride(const char* shaderName, OVERRIDE_SH m_shaderOverrideList.insert(new_override, 0); } -MatSysShaderPipelineCache& CMaterialSystem::GetRenderPipelineCache(int shaderNameHash) +MatSysShaderPipelineCache& CMaterialSystem::AcquireRenderPipelineCache(int shaderNameHash) +{ + CScopedMutex m(s_matSystemPipelineMutex); + MatSysShaderPipelineCache& cache = m_renderPipelineCache[shaderNameHash]; + ++cache.users; + return cache; +} + +void CMaterialSystem::ReleaseRenderPipelineCache(int shaderNameHash) +{ + CScopedMutex m(s_matSystemPipelineMutex); + auto it = m_renderPipelineCache.find(shaderNameHash); + if (it.atEnd()) + return; + + MatSysShaderPipelineCache& cache = *it; + ASSERT_MSG(cache.users > 0, "Pipeline cache has no users"); + if (cache.users > 0) + --cache.users; +} + +void CMaterialSystem::ReleaseSharedBindGroups() { CScopedMutex m(s_matSystemPipelineMutex); - return m_renderPipelineCache[shaderNameHash]; + for (auto it = m_renderPipelineCache.begin(); !it.atEnd(); ++it) + { + MatSysShaderPipelineCache& cache = *it; + Threading::CScopedWriteLocker lock(cache.rwLock); + for (MatSysShaderPipelineCache::SharedBindGroup& shared : cache.sharedBindGroups) + shared = MatSysShaderPipelineCache::SharedBindGroup{}; + } +} + +uint CMaterialSystem::GenerateRenderPassVersion() +{ + uint version = Atomic::Increment(m_renderPassVersion); + + // COM_UINT_MAX means 'any version' for shared bind groups + if (version == COM_UINT_MAX) + version = Atomic::Increment(m_renderPassVersion); + return version; } const MatSysShaderFactory* CMaterialSystem::GetShaderFactory(const char* szShaderName, int instanceFormatId) @@ -979,6 +1057,9 @@ void CMaterialSystem::QueueLoading(const IMaterialPtr& pMaterial) if(Atomic::CompareExchange(material->m_state, MATERIAL_LOAD_NEED_LOAD, MATERIAL_LOAD_INQUEUE) != MATERIAL_LOAD_NEED_LOAD) return; + // loaded material is not flushed by ReleaseUnusedMaterials until it was unused for materialFlushThresh frames + material->m_frameBound = m_frame; + if( m_config.threadedloader ) { s_threadedMaterialLoader.AddMaterial(pMaterial); @@ -1628,7 +1709,12 @@ bool CMaterialSystem::SetupMaterialPipeline(IMaterial* material, ArrayCRefGetState(); if (matState != MATERIAL_LOAD_OK && matState != MATERIAL_LOAD_ERROR) + { + // material was flushed by ReleaseUnusedMaterials (or never queued), load it again + if (matState == MATERIAL_LOAD_NEED_LOAD) + QueueLoading(IMaterialPtr(material)); return false; + } IShaderAPI* renderAPI = m_shaderAPI; @@ -1837,6 +1923,7 @@ void CMaterialSystem::PrintLoadedMaterials() const Msg("*** Render pipeline list begin ***\n"); int totalCached = 0; int totalShaders = 0; + int totalBindGroups = 0; for (auto it = m_shaderFactoryList.begin(); !it.atEnd(); ++it) { const MatSysShaderFactory& shaderFactory = *it; @@ -1847,8 +1934,13 @@ void CMaterialSystem::PrintLoadedMaterials() const Msg("shader %s: %d\n", shaderFactory.shaderName, pipelineCache.pipelines.size()); totalCached += pipelineCache.pipelines.size(); + for (const MatSysShaderPipelineCache::SharedBindGroup& sharedBindGroup : pipelineCache.sharedBindGroups) + { + if(sharedBindGroup.bindGroup) + totalBindGroups += 1; + } ++totalShaders; } - Msg("Total render pipelines: %d from %d shaders\n", totalCached, totalShaders); + Msg("Total: %d pipelines, %d bind groups from %d shaders\n", totalCached, totalBindGroups, totalShaders); } diff --git a/core/materialsystem1/MaterialSystem.h b/core/materialsystem1/MaterialSystem.h index 9d321f336..7c56eb2d9 100644 --- a/core/materialsystem1/MaterialSystem.h +++ b/core/materialsystem1/MaterialSystem.h @@ -97,7 +97,10 @@ class CMaterialSystem : public IMaterialSystem bool IsMaterialExist(const char* szMaterialName) const; const MatSysShaderFactory* GetShaderFactory(const char* szShaderName, int instanceFormatId); - MatSysShaderPipelineCache& GetRenderPipelineCache(int shaderNameHash); + MatSysShaderPipelineCache& AcquireRenderPipelineCache(int shaderNameHash); + void ReleaseRenderPipelineCache(int shaderNameHash); + void ReleaseSharedBindGroups(); + uint GenerateRenderPassVersion(); void PreloadNewMaterials(); bool WaitAllMaterialsLoaded(int timeout = -1); @@ -203,6 +206,7 @@ class CMaterialSystem : public IMaterialSystem Array m_proxyFactoryList{ PP_SL }; Map m_renderPipelineCache{ PP_SL }; + uint m_renderPassVersion{ 0 }; Map m_loadedMaterials{ PP_SL }; // loaded material list Array m_dynamicMeshes{ PP_SL }; diff --git a/core/materialsystem1/material.cpp b/core/materialsystem1/material.cpp index f0bdd6c99..323a44713 100644 --- a/core/materialsystem1/material.cpp +++ b/core/materialsystem1/material.cpp @@ -377,6 +377,10 @@ MatVarProxyUnk CMaterial::FindMaterialVar(const char* pszVarName) const const ITexturePtr& CMaterial::GetBaseTexture(int stage) { + // shader is dropped when the material is flushed by ReleaseUnusedMaterials + if (!m_shader && m_state == MATERIAL_LOAD_NEED_LOAD) + DoLoadShaderAndTextures(); + if(m_shader != nullptr && !IsError()) { DoLoadShaderAndTextures(); @@ -450,6 +454,23 @@ void CMaterial::Cleanup(bool dropVars, bool dropShader) Atomic::Exchange(m_state, MATERIAL_LOAD_NEED_LOAD); } +void CMaterial::Flush() +{ + WaitForLoading(); + + if (m_shader) + { + m_shader->Unload(); + SAFE_DELETE(m_shader); + } + + // shader data stored by shaders + m_storage.clear(true); + m_needUpdateGPUProxies = true; + + Atomic::Exchange(m_state, MATERIAL_LOAD_NEED_LOAD); +} + void CMaterial::OnMatVarChanged(int varIdx, void* userData) { reinterpret_cast(userData)->OnVarUpdated(); diff --git a/core/materialsystem1/material.h b/core/materialsystem1/material.h index 45e718d81..6cc536fde 100644 --- a/core/materialsystem1/material.h +++ b/core/materialsystem1/material.h @@ -45,6 +45,9 @@ class CMaterial : public IMaterial void Cleanup(bool dropVars = true, bool dropShader = true); + // unloads shader and its GPU resources but keeps vars and proxies; material is loaded again on next use + void Flush(); + bool LoadShaderAndTextures(); void WaitForLoading() const; diff --git a/docs/public_rendering_overview.md b/docs/public_rendering_overview.md index 4f9ca6097..2db6bebf9 100644 --- a/docs/public_rendering_overview.md +++ b/docs/public_rendering_overview.md @@ -52,6 +52,13 @@ Main responsibilities: - **Materials:** `GetMaterial(name)` (loads or returns cached), `CreateMaterial(name, params)` (from a `KVSection`), `IsMaterialExist`, `GetDefaultMaterial`, `QueueLoading`, `PreloadNewMaterials`, `WaitAllMaterialsLoaded`, `ReloadAllMaterials`, `ReleaseUnusedMaterials`. +- **Unused material release:** `ReleaseUnusedMaterials` flushes loaded materials not drawn for + `MaterialsInitSettings::materialFlushThresh` frames (`CMaterial::Flush`: the shader and its + textures are freed, vars and proxies are kept; a newly queued material counts as drawn) and drops + the pipeline caches of shader classes no shader object uses. A flushed material is queued for + loading again by its next `SetupMaterialPipeline` (that draw is skipped) or `GetBaseTexture`. + Call it only at a point where no render job uses materials; it is also the `mat_releaseUnused` + console command. - **Shaders:** `LoadShaderLibrary`, `RegisterShader`, `RegisterShaderOverride`, `ReloadShader`. - **Proxies:** `RegisterProxy(factory, name)`, `CreateProxyByName`, `SetProxyDeltaTime`. - **Global material variables:** `GetGlobalMaterialVarByName(name, default)` — variables shared by diff --git a/docs/rendering_matsystem_shaders_overview.md b/docs/rendering_matsystem_shaders_overview.md index c7a6e8fad..f998cc9c9 100644 --- a/docs/rendering_matsystem_shaders_overview.md +++ b/docs/rendering_matsystem_shaders_overview.md @@ -119,7 +119,10 @@ void BuildPipelineShaderQuery(Array& shaderQuery) const override streams, the material flags (cull, depth test / write, decal, Z-only, blend mode), the topology, the presence of an instance provider, the render target formats and MSAA. The pipeline is looked up in the shader object, then in the material-system-wide cache for this shader name -(`GetRenderPipelineCache`), and created otherwise: +(`AcquireRenderPipelineCache`), and created otherwise. Each shader object holds one user of the +cache from the first lookup until `Unload` (`ReleaseRenderPipelineCache`); +`ReleaseUnusedMaterials` drops caches without users, `ReloadAllMaterials` / `ReloadShader` drop +them after freeing the shaders. - `FillRenderPipelineDesc` builds the description: shader name and query, vertex layouts from the mesh instance format (only the streams in `usedLayoutBits`; an instance stream must be bound), @@ -131,6 +134,10 @@ in the shader object, then in the material-system-wide cache for this shader nam `false`) until the pipeline is ready. `RenderPassContext::waitForPipelines` or device validation create them synchronously. `r_showPipelineCacheMisses` logs pipelines created during rendering; `r_pipelineCacheDisable` disables the shared cache. +- The job owns its inputs (description, binding layout) and only resolves a future. The shader + object and the cache entry keep that future, and `EnsureRenderPipeline` polls it on the next + lookup. Nothing points back into the shader or material, so they may be freed (level unload, + `ReleaseUnusedMaterials`, material reload) while pipelines are still being created. ## Bind groups @@ -147,7 +154,13 @@ in the shader object, then in the material-system-wide cache for this shader nam constant groups). - `CreateBindGroup` — a new group each call (render pass / transient data). - `CreateSharedBindGroup` / `GetSharedBindGroup` — one group shared by all materials of the shader - class, with a version number. + class, with a version number. It is stored by the material system next to the pipeline cache of + the shader class (`MatSysShaderPipelineCache::sharedBindGroups`), not by materials, so it outlives + them. `GetSharedBindGroup` returns nothing when the version differs (`COM_UINT_MAX` matches any). + - Take versions from `IMaterialSystem::GenerateRenderPassVersion`: they are unique for the + process, so a group made for older render pass data never matches new data. + - Call `IMaterialSystem::ReleaseSharedBindGroups` before freeing resources the groups reference + (render targets, environment maps), e.g. on level unload. - `GetEmptyBindGroup` — for unused groups. `setupParams.uniformBuffers` holds buffers passed by the caller with a signature diff --git a/public/materialsystem1/BaseShader.cpp b/public/materialsystem1/BaseShader.cpp index beb4837f3..cad9dfa20 100644 --- a/public/materialsystem1/BaseShader.cpp +++ b/public/materialsystem1/BaseShader.cpp @@ -315,16 +315,16 @@ void CBaseShader::FillRenderPipelineDesc(const PipelineInputParams& inputParams, pipelineBuilder.End(); } +// Creates render pipeline on a worker thread. +// It owns everything it uses, so the material and shader that requested it may be freed meanwhile. class CBaseShader::PipelineCreatorJob : public IParallelJob { public: - using CachePipelineIterator = MatSysShaderPipelineCache::PipelineMap::Iterator; - - PipelineCreatorJob(IShaderAPI* renderAPI, const RenderPipelineDesc& pipelineDesc, PipelineInfo& pipelineInfo) + PipelineCreatorJob(IShaderAPI* renderAPI, const RenderPipelineDesc& pipelineDesc, const IGPUBindingLayoutPtr& layout) : IParallelJob(EqString::Format("PipelineCreator_%s", pipelineDesc.shaderName.ToCString())) , m_renderAPI(renderAPI) , m_pipelineDesc(pipelineDesc) - , m_pipelineInfo(pipelineInfo) + , m_layout(layout) {} void Execute() override; @@ -333,13 +333,13 @@ class CBaseShader::PipelineCreatorJob : public IParallelJob protected: RenderPipelineDesc m_pipelineDesc; - PipelineInfo& m_pipelineInfo; + IGPUBindingLayoutPtr m_layout; IShaderAPI* m_renderAPI; }; void CBaseShader::PipelineCreatorJob::Execute() { - IGPURenderPipelinePtr renderPipeline = m_renderAPI->CreateRenderPipeline(m_pipelineDesc, m_pipelineInfo.layout); + IGPURenderPipelinePtr renderPipeline = m_renderAPI->CreateRenderPipeline(m_pipelineDesc, m_layout); if (!renderPipeline) { m_pipelinePromise.SetError(-1, EqString::Format("%s cannot create render pipeline", GetName())); @@ -348,28 +348,86 @@ void CBaseShader::PipelineCreatorJob::Execute() m_pipelinePromise.SetResult(std::move(renderPipeline)); } +MatSysShaderPipelineCache& CBaseShader::GetPipelineCache() const +{ + if (MatSysShaderPipelineCache* cache = m_pipelineCache) + return *cache; + + // shader holds one user of the cache until Unload + Threading::CScopedWriteLocker lock(m_renderPipelinesLock); + if (!m_pipelineCache) + m_pipelineCache = &g_matSystem->AcquireRenderPipelineCache(GetNameHash()); + return *m_pipelineCache; +} + +IGPUBindGroupPtr CBaseShader::GetSharedBindGroup(EBindGroupId bindGroupId, uint version) const +{ + MatSysShaderPipelineCache& cache = GetPipelineCache(); + + Threading::CScopedReadLocker lock(cache.rwLock); + const MatSysShaderPipelineCache::SharedBindGroup& shared = cache.sharedBindGroups[bindGroupId]; + if (version != COM_UINT_MAX && version != shared.version) + return nullptr; + return shared.bindGroup; +} + +void CBaseShader::SetSharedBindGroup(EBindGroupId bindGroupId, IGPUBindGroup* bindGroup, uint version) const +{ + MatSysShaderPipelineCache& cache = GetPipelineCache(); + + Threading::CScopedWriteLocker lock(cache.rwLock); + MatSysShaderPipelineCache::SharedBindGroup& shared = cache.sharedBindGroups[bindGroupId]; + shared.bindGroup = IGPUBindGroupPtr(bindGroup); + shared.version = version; +} + +// Takes the result of asynchronous pipeline creation. +// Shader polls the future instead of being called back, so nothing references the shader from a job. +void CBaseShader::ResolvePendingPipeline(PipelineInfo& pipelineInfo) +{ + if (!pipelineInfo.pipelinePending) + return; + + Threading::CScopedWriteLocker lock(m_renderPipelinesLock); + if (!pipelineInfo.pipelinePending || !pipelineInfo.pipelineFuture.HasResult()) + return; + + if (!pipelineInfo.pipelineFuture.HasErrors()) + pipelineInfo.pipeline = pipelineInfo.pipelineFuture.GetResult(); + + pipelineInfo.pipelineFuture = nullptr; + pipelineInfo.pipelinePending = false; +} + const CBaseShader::PipelineInfo& CBaseShader::EnsureRenderPipeline(IShaderAPI* renderAPI, const PipelineInputParams& inputParams, uint pipelineId, bool onInit) { using namespace Threading; if (m_lastPipelineInfoHash == pipelineId && m_lastPipelineInfo) + { + ResolvePendingPipeline(*m_lastPipelineInfo); return *m_lastPipelineInfo; + } HOOK_TO_CVAR(r_showPipelineCacheMisses); HOOK_TO_CVAR(r_pipelineCacheDisable); PipelineInfoMap::Iterator it; - + { CScopedReadLocker lock(m_renderPipelinesLock); it = m_renderPipelines.find(pipelineId); - if (it) - { - m_lastPipelineInfo = &(*it); - m_lastPipelineInfoHash = pipelineId; - return *it; - } } + if (it) + { + PipelineInfo& foundPipelineInfo = *it; + ResolvePendingPipeline(foundPipelineInfo); + + m_lastPipelineInfo = &foundPipelineInfo; + m_lastPipelineInfoHash = pipelineId; + return foundPipelineInfo; + } + { CScopedWriteLocker lock(m_renderPipelinesLock); it = m_renderPipelines.insert(pipelineId); @@ -380,29 +438,33 @@ const CBaseShader::PipelineInfo& CBaseShader::EnsureRenderPipeline(IShaderAPI* r const bool usePipelineCache = r_pipelineCacheDisable->GetBool() == false; - MatSysShaderPipelineCache::PipelineMap::Iterator cacheIt; if (usePipelineCache) { - if (!m_pipelineCache) - m_pipelineCache = &g_matSystem->GetRenderPipelineCache(GetNameHash()); + MatSysShaderPipelineCache& pipelineCache = GetPipelineCache(); + MatSysShaderPipelineCache::Pipeline cachedPipeline; + bool foundInCache = false; { - CScopedReadLocker lock(m_pipelineCache->rwLock); - cacheIt = m_pipelineCache->pipelines.find(pipelineId); + CScopedReadLocker lock(pipelineCache.rwLock); + auto cacheIt = pipelineCache.pipelines.find(pipelineId); + if (cacheIt) + { + cachedPipeline = *cacheIt; + foundInCache = true; + } } - if (cacheIt) + + if (foundInCache) { - if ((*cacheIt).future) + newPipelineInfo.pipeline = cachedPipeline.pipeline; + newPipelineInfo.layout = cachedPipeline.layout; + if (!newPipelineInfo.pipeline && cachedPipeline.future) { - (*cacheIt).future.AddCallback([&cache = *cacheIt, &newPipelineInfo](const FutureResult& result) { - cache.pipeline = *result; - newPipelineInfo.pipeline = *result; - }); + newPipelineInfo.pipelineFuture = cachedPipeline.future; + newPipelineInfo.pipelinePending = true; + ResolvePendingPipeline(newPipelineInfo); } - newPipelineInfo.pipeline = (*cacheIt).pipeline; - newPipelineInfo.layout = (*cacheIt).layout; - m_lastPipelineInfo = &newPipelineInfo; m_lastPipelineInfoHash = pipelineId; return newPipelineInfo; @@ -412,7 +474,7 @@ const CBaseShader::PipelineInfo& CBaseShader::EnsureRenderPipeline(IShaderAPI* r // Create pipeline layout { // MatSystem shader by default defines three bind groups, which differ by the lifetime: - // + // // BINDGROUP_CONSTANT // - Bind group data is never going to be changed during the life time of the material // BINDGROUP_RENDERPASS @@ -461,36 +523,40 @@ const CBaseShader::PipelineInfo& CBaseShader::EnsureRenderPipeline(IShaderAPI* r if (usePipelineCache) { - CScopedWriteLocker lock(m_pipelineCache->rwLock); + MatSysShaderPipelineCache& pipelineCache = GetPipelineCache(); + CScopedWriteLocker lock(pipelineCache.rwLock); MatSysShaderPipelineCache::Pipeline cachePipeline; cachePipeline.layout = newPipelineInfo.layout; cachePipeline.pipeline = newPipelineInfo.pipeline; - m_pipelineCache->pipelines.insert(pipelineId, cachePipeline); + pipelineCache.pipelines.insert(pipelineId, cachePipeline); } } else { - PipelineCreatorJob* pipelineJob = PPNew PipelineCreatorJob(renderAPI, renderPipelineDesc, newPipelineInfo); + PipelineCreatorJob* pipelineJob = PPNew PipelineCreatorJob(renderAPI, renderPipelineDesc, newPipelineInfo.layout); pipelineJob->InitJob(); pipelineJob->DeleteOnFinish(); + Future pipelineFuture = pipelineJob->m_pipelinePromise.CreateFuture(); + newPipelineInfo.pipelineFuture = pipelineFuture; + newPipelineInfo.pipelinePending = true; + if (usePipelineCache) { + MatSysShaderPipelineCache& pipelineCache = GetPipelineCache(); + MatSysShaderPipelineCache::Pipeline cachePipeline; cachePipeline.layout = newPipelineInfo.layout; - cachePipeline.future = pipelineJob->m_pipelinePromise.CreateFuture(); - { - CScopedWriteLocker lock(m_pipelineCache->rwLock); - cacheIt = m_pipelineCache->pipelines.insert(pipelineId, cachePipeline); - } - cachePipeline.future.AddCallback([&cache = *cacheIt, &newPipelineInfo, onInit, inputParams, this](const FutureResult& result) { - if (result.IsError()) - { - MsgError("Material %s produced error:\n%s\n", this->m_material->GetName(), result.GetErrorMessage()); - return; - } + cachePipeline.future = pipelineFuture; + + CScopedWriteLocker lock(pipelineCache.rwLock); + pipelineCache.pipelines.insert(pipelineId, cachePipeline); + } + // The callback only reports the result. It captures values, not the shader or material, + // which may be freed before the job completes. + EqString pipelineName; #ifndef _RETAIL #define BYTE_TO_BINARY_PATTERN "%c%c%c%c%c%c%c%c" @@ -502,44 +568,35 @@ const CBaseShader::PipelineInfo& CBaseShader::EnsureRenderPipeline(IShaderAPI* r ((byte) & 0x08 ? '1' : '0'), \ ((byte) & 0x04 ? '1' : '0'), \ ((byte) & 0x02 ? '1' : '0'), \ -((byte) & 0x01 ? '1' : '0') - - if (*result && r_showPipelineCacheMisses->GetBool()) - { - if (onInit) - { - MsgInfo("Pre-create pipeline %s-%s_" BYTE_TO_BINARY_PATTERN "-PT%d-DF%d-CF%d\n", - GetName(), inputParams.meshInstFormat.name, - BYTE_TO_BINARY(inputParams.meshInstFormat.usedLayoutBits), - inputParams.primitiveTopology, inputParams.depthTargetFormat, inputParams.colorTargetFormat[0]); - } - else - { - MsgWarning("Render pipeline %s-%s_" BYTE_TO_BINARY_PATTERN "-PT%d-DF%d-CF%d was not pre-created\n", - GetName(), inputParams.meshInstFormat.name, - BYTE_TO_BINARY(inputParams.meshInstFormat.usedLayoutBits), - inputParams.primitiveTopology, inputParams.depthTargetFormat, inputParams.colorTargetFormat[0]); - } - } -#endif // !_RETAIL +((byte) & 0x01 ? '1' : '0') - cache.pipeline = *result; - newPipelineInfo.pipeline = *result; - cache.future = nullptr; - }); - } - else + if (r_showPipelineCacheMisses->GetBool()) { - Future pipelineFuture = pipelineJob->m_pipelinePromise.CreateFuture(); - pipelineFuture.AddCallback([&newPipelineInfo, onInit, inputParams, this](const FutureResult& result) { - if (result.IsError()) - { - MsgError("%s\n", result.GetErrorMessage()); - return; - } - newPipelineInfo.pipeline = *result; - }); + const int colorTargetFormat = inputParams.colorTargetFormat.numElem() ? inputParams.colorTargetFormat[0] : FORMAT_NONE; + pipelineName = EqString::Format("%s-%s_" BYTE_TO_BINARY_PATTERN "-PT%d-DF%d-CF%d", + GetName(), inputParams.meshInstFormat.name, + BYTE_TO_BINARY(inputParams.meshInstFormat.usedLayoutBits), + inputParams.primitiveTopology, inputParams.depthTargetFormat, colorTargetFormat); } +#undef BYTE_TO_BINARY_PATTERN +#undef BYTE_TO_BINARY +#endif // !_RETAIL + + pipelineFuture.AddCallback([materialName = EqString(m_material->GetName()), pipelineName, onInit](const FutureResult& result) { + if (result.IsError()) + { + MsgError("Material %s produced error:\n%s\n", materialName.ToCString(), result.GetErrorMessage()); + return; + } + + if (pipelineName.Length()) + { + if (onInit) + MsgInfo("Pre-create pipeline %s\n", pipelineName.ToCString()); + else + MsgWarning("Render pipeline %s was not pre-created\n", pipelineName.ToCString()); + } + }); g_parallelJobs->GetJobMng()->StartJob(pipelineJob); } @@ -684,7 +741,11 @@ void CBaseShader::InitShader(IShaderAPI* renderAPI) // Unload shaders, textures void CBaseShader::Unload() { + if (m_pipelineCache) + g_matSystem->ReleaseRenderPipelineCache(GetNameHash()); m_pipelineCache = nullptr; + m_lastPipelineInfo = nullptr; + m_lastPipelineInfoHash = 0; m_renderPipelines.clear(true); for (int i = 0; i < m_usedTextures.numElem(); ++i) { diff --git a/public/materialsystem1/BaseShader.h b/public/materialsystem1/BaseShader.h index febb7939a..ae33b8034 100644 --- a/public/materialsystem1/BaseShader.h +++ b/public/materialsystem1/BaseShader.h @@ -25,8 +25,6 @@ struct MatSysCamera; } \ class C##name##Shader : public CBaseShader { \ public: \ - inline static uint s_sharedBindGroupVersion[MAX_BINDGROUPS] = { COM_UINT_MAX, COM_UINT_MAX, COM_UINT_MAX, COM_UINT_MAX }; \ - inline static IGPUBindGroup* s_sharedBindGroup[MAX_BINDGROUPS]; \ ArrayCRef GetSupportedVertexLayoutIds() const override { \ return C##name##ShaderLocalNamespace::GetSupportedVertexLayoutIds(); \ } \ @@ -35,20 +33,6 @@ struct MatSysCamera; C##name##Shader(IMaterial* material) : CBaseShader(material) {} \ void Init(IShaderAPI* renderAPI) override { \ CBaseShader::Init(renderAPI); ShaderInitParams(renderAPI); \ - } \ - void Unload() override { \ - CBaseShader::Unload(); \ - for (int i = 0; i < BINDGROUP_COUNT; ++i) { if(s_sharedBindGroup[i] && s_sharedBindGroup[i]->Ref_Drop()) s_sharedBindGroup[i] = nullptr; } \ - } \ - IGPUBindGroupPtr GetSharedBindGroup(EBindGroupId bindGroupId, uint version) const override { \ - if(version != COM_UINT_MAX && version != s_sharedBindGroupVersion[bindGroupId]) return nullptr; \ - return IGPUBindGroupPtr(s_sharedBindGroup[bindGroupId]); \ - } \ - void SetSharedBindGroup(EBindGroupId bindGroupId, IGPUBindGroup* bindGroup, uint version) const override { \ - bindGroup->Ref_Grab(); \ - if(s_sharedBindGroup[bindGroupId]) s_sharedBindGroup[bindGroupId]->Ref_Drop(); \ - s_sharedBindGroup[bindGroupId] = bindGroup; \ - s_sharedBindGroupVersion[bindGroupId] = version; \ } #define END_SHADER_CLASS }; \ @@ -164,8 +148,10 @@ class CBaseShader : public IMatSystemShader mutable IGPUBindGroupPtr bindGroup[MAX_BINDGROUPS]; // pipeline bind groups IGPURenderPipelinePtr pipeline; IGPUBindingLayoutPtr layout; + Future pipelineFuture; // pending asynchronous pipeline, polled by EnsureRenderPipeline int vertexLayoutId{ 0 }; mutable uint instMngToken{ COM_UINT_MAX }; + bool pipelinePending{ false }; }; struct BindGroupSetupParams @@ -178,8 +164,10 @@ class CBaseShader : public IMatSystemShader uint pipelineId{ 0 }; }; - virtual IGPUBindGroupPtr GetSharedBindGroup(EBindGroupId bindGroupId, uint version) const = 0; - virtual void SetSharedBindGroup(EBindGroupId bindGroupId, IGPUBindGroup* bindGroup, uint version) const = 0; + // shared bind groups are stored per shader class by material system (see MatSysShaderPipelineCache) + IGPUBindGroupPtr GetSharedBindGroup(EBindGroupId bindGroupId, uint version) const; + void SetSharedBindGroup(EBindGroupId bindGroupId, IGPUBindGroup* bindGroup, uint version) const; + MatSysShaderPipelineCache& GetPipelineCache() const; virtual IGPUBindGroupPtr GetBindGroup(IShaderAPI* renderAPI, EBindGroupId bindGroupId, const BindGroupSetupParams& setupParams) const { return nullptr; } virtual void FillBindGroupLayout_Constant(const MeshInstanceFormatRef& meshInstFormat, BindGroupLayoutDesc& bindGroupLayout) const {} @@ -194,6 +182,7 @@ class CBaseShader : public IMatSystemShader virtual void BuildPipelineShaderQuery(Array& shaderQuery) const {} const PipelineInfo& EnsureRenderPipeline(IShaderAPI* renderAPI, const PipelineInputParams& inputParams, uint pipelineId, bool onInit); + void ResolvePendingPipeline(PipelineInfo& pipelineInfo); IGPUBindGroupPtr CreatePersistentBindGroup(BindGroupDesc& bindGroupDesc, EBindGroupId bindGroupId, IShaderAPI* renderAPI, const PipelineInfo& pipelineInfo) const; IGPUBindGroupPtr CreateSharedBindGroup(BindGroupDesc& bindGroupDesc, EBindGroupId bindGroupId, uint version, IShaderAPI* renderAPI, const PipelineInfo& pipelineInfo) const; @@ -224,7 +213,7 @@ class CBaseShader : public IMatSystemShader uint m_lastPipelineInfoHash{ 0 }; mutable PipelineInfoMap m_renderPipelines{ PP_SL }; - MatSysShaderPipelineCache* m_pipelineCache{ nullptr }; + mutable MatSysShaderPipelineCache* m_pipelineCache{ nullptr }; IMaterial* m_material{ nullptr }; diff --git a/public/materialsystem1/IMatSysShader.h b/public/materialsystem1/IMatSysShader.h index 25a41768c..e8e7ea1ce 100644 --- a/public/materialsystem1/IMatSysShader.h +++ b/public/materialsystem1/IMatSysShader.h @@ -55,18 +55,30 @@ enum EBindGroupId : int BINDGROUP_COUNT }; +// Per-shader-class data owned by the material system. +// Shader instances never keep references into it beyond a single call: pending pipelines are +// shared through futures and shaders poll them, so material can be freed at any time. struct MatSysShaderPipelineCache { struct Pipeline { - Future future; + Future future; // valid while pipeline is created asynchronously IGPUBindingLayoutPtr layout; IGPURenderPipelinePtr pipeline; }; using PipelineMap = Map; + // bind group shared between all materials of the shader class (e.g. render pass data) + struct SharedBindGroup + { + IGPUBindGroupPtr bindGroup; + uint version{ COM_UINT_MAX }; + }; + PipelineMap pipelines{ PP_SL }; + SharedBindGroup sharedBindGroups[BINDGROUP_COUNT]; Threading::CEqReadWriteLock rwLock; + int users{ 0 }; // shader objects using the cache; unused caches are dropped by ReleaseUnusedMaterials }; class IShaderMeshInstanceProvider diff --git a/public/materialsystem1/IMaterialSystem.h b/public/materialsystem1/IMaterialSystem.h index 15c170287..801e25714 100644 --- a/public/materialsystem1/IMaterialSystem.h +++ b/public/materialsystem1/IMaterialSystem.h @@ -133,7 +133,17 @@ class IMaterialSystem : public IEqCoreModule virtual bool IsMaterialExist(const char* szMaterialName) const = 0; virtual const MatSysShaderFactory* GetShaderFactory(const char* szShaderName, int instanceFormatId) = 0; - virtual MatSysShaderPipelineCache& GetRenderPipelineCache(int shaderNameHash) = 0; + // returns pipeline cache of the shader class and adds a user to it. The cache stays valid until + // ReleaseRenderPipelineCache is called by that user (or material reload drops it). + virtual MatSysShaderPipelineCache& AcquireRenderPipelineCache(int shaderNameHash) = 0; + virtual void ReleaseRenderPipelineCache(int shaderNameHash) = 0; + + // drops shader shared bind groups (and resources they reference) of all shader classes. + // Call it when render pass resources (render targets, env maps) are about to be freed. + virtual void ReleaseSharedBindGroups() = 0; + + // returns unique render pass data version so stale shared bind groups never match new data + virtual uint GenerateRenderPassVersion() = 0; virtual void QueueLoading(const IMaterialPtr& pMaterial) = 0; virtual void PreloadNewMaterials() = 0; @@ -142,6 +152,9 @@ class IMaterialSystem : public IEqCoreModule virtual void ReloadAllMaterials() = 0; virtual void ReloadShader(const char* name) = 0; + // unloads shaders and textures of materials not drawn for MaterialsInitSettings::materialFlushThresh frames + // (they are loaded again on next use) and drops pipeline caches of shader classes nobody uses. + // Call it only when no rendering job uses materials. virtual void ReleaseUnusedMaterials() = 0; virtual void FreeMaterial(IMaterial* pMaterial) = 0; From 82404a58a97cc4cdd19f0e9bffd6b696e93db1d9 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 04:20:58 +0500 Subject: [PATCH 18/20] - imgui: add ImGui_ImplMatSystem_BeginOpaque / EndOpaque and AddImageOpaque to draw textures without blending - sys: host viewport draws its color target without blending - equi: editor preview target is drawn without blending - docs: describe opaque ImGui image drawing in sys_application_overview.md Co-Authored-By: Claude Opus 5.5 --- docs/sys_application_overview.md | 11 +++++- shared/equi/EqUI_Editor.cpp | 4 ++ shared/imgui_backend/imgui_impl_matsystem.cpp | 37 +++++++++++++++++-- shared/imgui_backend/imgui_impl_matsystem.h | 8 ++++ shared/sys/sys_viewport.cpp | 4 +- 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/sys_application_overview.md b/docs/sys_application_overview.md index f2d69cd52..fe9be39bf 100644 --- a/docs/sys_application_overview.md +++ b/docs/sys_application_overview.md @@ -154,6 +154,14 @@ ImGui can put more than 65535 vertices into one draw list with 16-bit indices, a commands by triangles into chunks that are packed into dynamic meshes. User callbacks draw the pending chunks first. +ImGui draws everything with translucent blending, so a texture's alpha is treated as coverage. +Render targets whose alpha is not coverage (scene targets, previews) are drawn without blending: +`ImGui_ImplMatSystem_AddImageOpaque`, or `ImGui_ImplMatSystem_BeginOpaque` / +`ImGui_ImplMatSystem_EndOpaque` around draw list commands (`imgui_impl_matsystem.h`). These add draw +list callbacks, so each block flushes the pending chunks; group many images into one block. +Anti-aliased lines and text inside a block lose their smooth edges. Blending is reset to translucent +at the start of each draw list and on `ImDrawCallback_ResetRenderState`. + `GetFrameTime()` returns the current frame time; `GetWindowSize()`, `GetDefaultFont()`, `GetWindowHandle()`. @@ -233,7 +241,8 @@ g_renderAPI->BeginRenderPass( - `DrawImGui(size)` resizes the color and depth targets (backbuffer formats and samplers, so the depth target can be sampled like the default depth buffer; `ResizeRenderTarget`) to the drawn size and puts an invisible button with the image in the current ImGui window. The - viewport is *visible* only in a frame where `DrawImGui` was called. + image is drawn without blending (`ImGui_ImplMatSystem_AddImageOpaque`): the alpha written by the + state is ignored. The viewport is *visible* only in a frame where `DrawImGui` was called. - Viewports are disabled in `Retail` and `Profile` builds (`HOST_VIEWPORT_ENABLED` in `sys_viewport.h`): `DrawImGui` does nothing and the viewport is never visible. - When the viewport is not visible (ImGui window closed, `Retail` or `Profile` build), diff --git a/shared/equi/EqUI_Editor.cpp b/shared/equi/EqUI_Editor.cpp index adc456824..7c20a0c8b 100644 --- a/shared/equi/EqUI_Editor.cpp +++ b/shared/equi/EqUI_Editor.cpp @@ -17,6 +17,7 @@ #ifdef EQUI_EDITOR #include +#include "imgui_backend/imgui_impl_matsystem.h" namespace equi::Editor { @@ -1404,7 +1405,10 @@ void CUIEditor::DrawPreviewTab() ImGui::SetCursorScreenPos(origin); } + // preview target alpha is not coverage + ImGui_ImplMatSystem_BeginOpaque(ImGui::GetWindowDrawList()); ImGui::Image(reinterpret_cast(m_previewTarget.Ptr()), displaySize); + ImGui_ImplMatSystem_EndOpaque(ImGui::GetWindowDrawList()); const bool previewHovered = ImGui::IsItemHovered(); const bool previewClicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); diff --git a/shared/imgui_backend/imgui_impl_matsystem.cpp b/shared/imgui_backend/imgui_impl_matsystem.cpp index 8b3db1acb..94ea2e22c 100644 --- a/shared/imgui_backend/imgui_impl_matsystem.cpp +++ b/shared/imgui_backend/imgui_impl_matsystem.cpp @@ -10,9 +10,8 @@ // DirectX data struct ImGui_ImplMatSystem_Data { - ITexturePtr FontTexture; - - ImGui_ImplMatSystem_Data() { memset(this, 0, sizeof(*this)); } + ITexturePtr FontTexture; + EShaderBlendMode BlendMode{ SHADER_BLEND_TRANSLUCENT }; // set by BeginOpaque / EndOpaque callbacks }; // Backend data stored in io.BackendRendererUserData to allow support for multiple Dear ImGui contexts @@ -22,6 +21,30 @@ static ImGui_ImplMatSystem_Data* ImGui_ImplMatSystem_GetBackendData() return ImGui::GetCurrentContext() ? (ImGui_ImplMatSystem_Data*)ImGui::GetIO().BackendRendererUserData : nullptr; } +// callback data is non-null for BeginOpaque +static void ImGui_ImplMatSystem_OpaqueCallback(const ImDrawList* parent_list, const ImDrawCmd* cmd) +{ + ImGui_ImplMatSystem_Data* bd = ImGui_ImplMatSystem_GetBackendData(); + bd->BlendMode = cmd->UserCallbackData ? SHADER_BLEND_NONE : SHADER_BLEND_TRANSLUCENT; +} + +void ImGui_ImplMatSystem_BeginOpaque(ImDrawList* drawList) +{ + drawList->AddCallback(ImGui_ImplMatSystem_OpaqueCallback, reinterpret_cast(1)); +} + +void ImGui_ImplMatSystem_EndOpaque(ImDrawList* drawList) +{ + drawList->AddCallback(ImGui_ImplMatSystem_OpaqueCallback, nullptr); +} + +void ImGui_ImplMatSystem_AddImageOpaque(ImDrawList* drawList, ImTextureID texture, const ImVec2& pMin, const ImVec2& pMax, const ImVec2& uvMin, const ImVec2& uvMax, ImU32 col) +{ + ImGui_ImplMatSystem_BeginOpaque(drawList); + drawList->AddImage(texture, pMin, pMax, uvMin, uvMax, col); + ImGui_ImplMatSystem_EndOpaque(drawList); +} + static void ImGui_ImplMatSystem_SetupRenderState(ImDrawData* draw_data, IGPURenderPassRecorder* rendPassRecorder, const RenderDrawCmd& drawCmd) { MatSysDefaultRenderPass defaultRenderPass; @@ -109,7 +132,7 @@ static void ImGui_ImplMatSystem_DrawChunks(ImDrawData* draw_data, IGPURenderPass // Apply Scissor/clipping rectangle, Bind texture, Draw MatSysDefaultRenderPass defaultRenderPass; - defaultRenderPass.blendMode = SHADER_BLEND_TRANSLUCENT; + defaultRenderPass.blendMode = ImGui_ImplMatSystem_GetBackendData()->BlendMode; defaultRenderPass.texture = ITexturePtr((ITexture*)pcmd->GetTexID()); g_matSystem->SetupMaterialPipeline(g_matSystem->GetDefaultMaterial(), nullptr, drawCmd.batchInfo.primTopology, drawCmd.instanceInfo.instFormat, RenderPassContext(rendPassRecorder, &defaultRenderPass)); @@ -128,12 +151,15 @@ void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRec if (draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f) return; + ImGui_ImplMatSystem_Data* bd = ImGui_ImplMatSystem_GetBackendData(); + Array chunks(PP_SL); RenderDrawCmd drawCmd; for (int n = 0; n < draw_data->CmdListsCount; n++) { const ImDrawList* cmd_list = draw_data->CmdLists[n]; + bd->BlendMode = SHADER_BLEND_TRANSLUCENT; // chunks waiting for a mesh int batchVertices = 0; @@ -170,6 +196,7 @@ void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRec // (ImDrawCallback_ResetRenderState is a special callback value used by the user to request the renderer to reset render state.) if (pcmd->UserCallback == ImDrawCallback_ResetRenderState) { + bd->BlendMode = SHADER_BLEND_TRANSLUCENT; if (drawCmd.instanceInfo.vertexBuffers[0].buffer) ImGui_ImplMatSystem_SetupRenderState(draw_data, rendPassRecorder, drawCmd); } @@ -218,6 +245,8 @@ void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data, IGPURenderPassRec flushChunks(); } + + bd->BlendMode = SHADER_BLEND_TRANSLUCENT; } bool ImGui_ImplMatSystem_Init() diff --git a/shared/imgui_backend/imgui_impl_matsystem.h b/shared/imgui_backend/imgui_impl_matsystem.h index 5d4a57bd8..9777f83f4 100644 --- a/shared/imgui_backend/imgui_impl_matsystem.h +++ b/shared/imgui_backend/imgui_impl_matsystem.h @@ -12,3 +12,11 @@ IMGUI_IMPL_API void ImGui_ImplMatSystem_RenderDrawData(ImDrawData* draw_data // Use if you want to reset your rendering device without losing Dear ImGui state. IMGUI_IMPL_API bool ImGui_ImplMatSystem_CreateDeviceObjects(); IMGUI_IMPL_API void ImGui_ImplMatSystem_InvalidateDeviceObjects(); + +// Draw list commands between BeginOpaque and EndOpaque are drawn without blending, so texture alpha +// is ignored (render targets whose alpha is not coverage). Blending is restored for each draw list. +IMGUI_IMPL_API void ImGui_ImplMatSystem_BeginOpaque(ImDrawList* drawList); +IMGUI_IMPL_API void ImGui_ImplMatSystem_EndOpaque(ImDrawList* drawList); + +// AddImage drawn without blending +IMGUI_IMPL_API void ImGui_ImplMatSystem_AddImageOpaque(ImDrawList* drawList, ImTextureID texture, const ImVec2& pMin, const ImVec2& pMax, const ImVec2& uvMin = ImVec2(0, 0), const ImVec2& uvMax = ImVec2(1, 1), ImU32 col = IM_COL32_WHITE); diff --git a/shared/sys/sys_viewport.cpp b/shared/sys/sys_viewport.cpp index 2e80c40cb..c6ca1b853 100644 --- a/shared/sys/sys_viewport.cpp +++ b/shared/sys/sys_viewport.cpp @@ -14,6 +14,7 @@ // enabled by sys_viewport.h #ifdef HOST_VIEWPORT_ENABLED #include +#include "imgui_backend/imgui_impl_matsystem.h" #endif // HOST_VIEWPORT_ENABLED CHostViewport::CHostViewport(const char* name) @@ -87,7 +88,8 @@ void CHostViewport::DrawImGui(const Vector2D& size) m_hovered = ImGui::IsItemHovered() || ImGui::IsItemActive(); m_focused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); - ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(m_colorTarget.Ptr()), screenPos, screenEnd); + // alpha of the state render target is not coverage + ImGui_ImplMatSystem_AddImageOpaque(ImGui::GetWindowDrawList(), reinterpret_cast(m_colorTarget.Ptr()), screenPos, screenEnd); m_screenRect = IAARectangle(static_cast(screenPos.x), static_cast(screenPos.y), static_cast(screenEnd.x), static_cast(screenEnd.y)); m_drawFrame = ImGui::GetFrameCount(); From 088996c1052ad47cf3472ced147ac9b19e5b8ab6 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 15:06:44 +0500 Subject: [PATCH 19/20] - rhi: draw count buffer of MultiDraw[Indexed]Indirect is optional, add multiDrawIndirectCountSupport caps - nvrhi: fix indirect count buffer state, draw exactly maxDrawCount commands without count buffer - nvrhi: VK - enable multiDrawIndirect, drawIndirectFirstInstance and drawIndirectCount from device features - nvrhi: D3D12 - report multiDrawIndirectCountSupport - wgpu: request IndirectFirstInstance, set multi-draw caps from device features, allow null count buffer - grim: allocate archetype draw infos as contiguous ranges sorted by material and topology - grim: draw runs of commands of one archetype with MultiDrawIndexedIndirect, add grim_dbgMultiDrawOff - grim: do not draw commands outside of indirect buffer - docs: describe GRIM multi-draw in rendering_grim_overview.md Co-Authored-By: Claude Opus 5.5 --- .../Renderers/NVRHI/NVRHILibraryD3D12.cpp | 1 + .../Renderers/NVRHI/NVRHILibraryVK.cpp | 17 +- .../NVRHI/NVRHIRenderPassRecorder.cpp | 18 +- .../Renderers/WGPU/WGPULibrary.cpp | 14 +- .../Renderers/WGPU/WGPURenderPassRecorder.cpp | 4 +- docs/rendering_grim_overview.md | 34 ++- .../renderers/IGPUCommandRecorder.h | 3 + .../materialsystem1/renderers/ShaderAPICaps.h | 5 +- shared/grim/GrimBaseRenderer.cpp | 247 ++++++++++++++---- shared/grim/GrimBaseRenderer.h | 18 +- 10 files changed, 283 insertions(+), 78 deletions(-) diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHILibraryD3D12.cpp b/core/materialsystem1/Renderers/NVRHI/NVRHILibraryD3D12.cpp index 17acec832..e7054f7a3 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHILibraryD3D12.cpp +++ b/core/materialsystem1/Renderers/NVRHI/NVRHILibraryD3D12.cpp @@ -231,6 +231,7 @@ bool CNVRHIRenderLibD3D12::InitAPI(const ShaderAPIParams& params) caps.maxComputeWorkgroupsPerDimension = 65535; caps.multiDrawIndirectSupport = false; caps.multiDrawIndexedIndirectSupport = true; + caps.multiDrawIndirectCountSupport = true; caps.shadersSupportedFlags = SHADER_CAPS_VERTEX_SUPPORTED | SHADER_CAPS_PIXEL_SUPPORTED diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHILibraryVK.cpp b/core/materialsystem1/Renderers/NVRHI/NVRHILibraryVK.cpp index fc6cfe0b9..62e6defa7 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHILibraryVK.cpp +++ b/core/materialsystem1/Renderers/NVRHI/NVRHILibraryVK.cpp @@ -365,7 +365,8 @@ bool CNVRHIRenderLibVK::InitAPI(const ShaderAPIParams& params) caps.maxComputeWorkgroupSizeZ = 64; caps.maxComputeWorkgroupsPerDimension = 65535; caps.multiDrawIndirectSupport = false; - caps.multiDrawIndexedIndirectSupport = true; + caps.multiDrawIndexedIndirectSupport = false; // set by device features in CreateDevice + caps.multiDrawIndirectCountSupport = false; caps.shadersSupportedFlags = SHADER_CAPS_VERTEX_SUPPORTED | SHADER_CAPS_PIXEL_SUPPORTED @@ -714,9 +715,15 @@ bool CNVRHIRenderLibVK::CreateDevice() // get/set shading rate features which are detected individually by nvrhi (not just at extension level) vk::PhysicalDeviceFeatures2 actualDeviceFeatures2; vk::PhysicalDeviceFragmentShadingRateFeaturesKHR fragmentShadingRateFeatures; + vk::PhysicalDeviceVulkan12Features actualVulkan12Features; actualDeviceFeatures2.pNext = &fragmentShadingRateFeatures; + fragmentShadingRateFeatures.pNext = &actualVulkan12Features; m_vkPhysicalDevice.getFeatures2( &actualDeviceFeatures2 ); + // multi-draw is only reported when indirect commands may also use firstInstance + const bool multiDrawIndirectSupported = actualDeviceFeatures2.features.multiDrawIndirect && actualDeviceFeatures2.features.drawIndirectFirstInstance; + const bool drawIndirectCountSupported = multiDrawIndirectSupported && actualVulkan12Features.drawIndirectCount; + auto vrsFeatures = vk::PhysicalDeviceFragmentShadingRateFeaturesKHR() .setPipelineFragmentShadingRate( fragmentShadingRateFeatures.pipelineFragmentShadingRate ) .setPrimitiveFragmentShadingRate( fragmentShadingRateFeatures.primitiveFragmentShadingRate ) @@ -762,7 +769,9 @@ bool CNVRHIRenderLibVK::CreateDevice() #endif .setFillModeNonSolid( true ) .setImageCubeArray( true ) - .setDualSrcBlend( true ); + .setDualSrcBlend( true ) + .setMultiDrawIndirect( actualDeviceFeatures2.features.multiDrawIndirect ) + .setDrawIndirectFirstInstance( actualDeviceFeatures2.features.drawIndirectFirstInstance ); auto vulkan12features = vk::PhysicalDeviceVulkan12Features() .setDescriptorIndexing( true ) @@ -772,6 +781,7 @@ bool CNVRHIRenderLibVK::CreateDevice() .setTimelineSemaphore( true ) .setShaderSampledImageArrayNonUniformIndexing( true ) .setBufferDeviceAddress( bufferAddressSupported ) + .setDrawIndirectCount( drawIndirectCountSupported ) .setPNext( pNext ); Array vkDevExtNames(PP_SL); @@ -824,6 +834,9 @@ bool CNVRHIRenderLibVK::CreateDevice() caps.renderTargetFormatsSupported[FORMAT_D24S8] = caps.renderTargetFormatsSupported[FORMAT_D24] = (ret == vk::Result::eSuccess); + caps.multiDrawIndexedIndirectSupport = multiDrawIndirectSupported; + caps.multiDrawIndirectCountSupport = drawIndirectCountSupported; + // Determine which Vulkan surface present modes are supported by device and surface auto surfacePModes = m_vkPhysicalDevice.getSurfacePresentModesKHR( m_defaultSwapChain->m_vkWindowSurface ); m_enablePModeMailbox = find( surfacePModes.begin(), surfacePModes.end(), vk::PresentModeKHR::eMailbox ) != surfacePModes.end(); diff --git a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderPassRecorder.cpp b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderPassRecorder.cpp index 8f21e1edf..d517e0c25 100644 --- a/core/materialsystem1/Renderers/NVRHI/NVRHIRenderPassRecorder.cpp +++ b/core/materialsystem1/Renderers/NVRHI/NVRHIRenderPassRecorder.cpp @@ -54,7 +54,7 @@ void CNVRHIRenderPassRecorder::CommitGraphicsState(nvrhi::IBuffer* indirectBuffe m_rhiCommandList->setBufferState(indirectBuffer, nvrhi::ResourceStates::IndirectArgument); if(indirectCountBuffer) - m_rhiCommandList->setBufferState(indirectBuffer, nvrhi::ResourceStates::IndirectArgument); + m_rhiCommandList->setBufferState(indirectCountBuffer, nvrhi::ResourceStates::IndirectArgument); // FIXME: should be same count as render targets? auto rhiViewportState = nvrhi::ViewportState() @@ -279,13 +279,16 @@ void CNVRHIRenderPassRecorder::MultiDrawIndexedIndirect(IGPUBuffer* indirectBuff ASSERT(indirectBufferImpl); ASSERT_MSG(indirectBufferImpl->GetUsageFlags() & BUFFERUSAGE_INDIRECT, "buffer doesn't have Indirect buffer usage bit"); + // draw count buffer is optional, without it maxDrawCount commands are drawn CNVRHIBuffer* drawCountBufferImpl = static_cast(drawCountBuffer); - ASSERT(drawCountBufferImpl); - ASSERT_MSG(drawCountBufferImpl->GetUsageFlags() & BUFFERUSAGE_INDIRECT, "buffer doesn't have Indirect buffer usage bit"); + ASSERT_MSG(!drawCountBufferImpl || (drawCountBufferImpl->GetUsageFlags() & BUFFERUSAGE_INDIRECT), "buffer doesn't have Indirect buffer usage bit"); + + if (maxDrawCount <= 0) + return; if (!IsViewportAndScissorValid()) { - DbgAddMarker("DrawIndexedIndirect skip"); + DbgAddMarker("MultiDrawIndexedIndirect skip"); return; } @@ -294,9 +297,12 @@ void CNVRHIRenderPassRecorder::MultiDrawIndexedIndirect(IGPUBuffer* indirectBuff m_lastIndirectBuffer = indirectBufferImpl; m_lastDrawCountBuffer = drawCountBufferImpl; - CommitGraphicsState(indirectBufferImpl->GetNVRHIBufferHandle(), drawCountBufferImpl->GetNVRHIBufferHandle()); + CommitGraphicsState(indirectBufferImpl->GetNVRHIBufferHandle(), drawCountBufferImpl ? drawCountBufferImpl->GetNVRHIBufferHandle() : nullptr); - m_rhiCommandList->drawIndexedIndirectCount(indirectOffset, drawCountBufferOffset, maxDrawCount); + if (drawCountBufferImpl) + m_rhiCommandList->drawIndexedIndirectCount(indirectOffset, drawCountBufferOffset, maxDrawCount); + else + m_rhiCommandList->drawIndexedIndirect(indirectOffset, maxDrawCount); ShaderAPIStats& stats = CNVRHIRenderAPI::Instance.GetStatsMutable(); Atomic::Increment(stats.indirectDrawCount); diff --git a/core/materialsystem1/Renderers/WGPU/WGPULibrary.cpp b/core/materialsystem1/Renderers/WGPU/WGPULibrary.cpp index f231bc16b..ece3d7299 100644 --- a/core/materialsystem1/Renderers/WGPU/WGPULibrary.cpp +++ b/core/materialsystem1/Renderers/WGPU/WGPULibrary.cpp @@ -243,8 +243,9 @@ bool CWGPURenderLib::InitAPI(const ShaderAPIParams& params) caps.maxComputeWorkgroupSizeY = supLimits.maxComputeWorkgroupSizeY; caps.maxComputeWorkgroupSizeZ = supLimits.maxComputeWorkgroupSizeZ; caps.maxComputeWorkgroupsPerDimension = supLimits.maxComputeWorkgroupsPerDimension; - caps.multiDrawIndirectSupport = wgpuAdapterHasFeature(m_rhiAdapter, WGPUFeatureName_MultiDrawIndirect); - caps.multiDrawIndexedIndirectSupport = caps.multiDrawIndirectSupport; + caps.multiDrawIndirectSupport = false; // set by device features after device is created + caps.multiDrawIndexedIndirectSupport = false; + caps.multiDrawIndirectCountSupport = false; caps.shadersSupportedFlags = SHADER_CAPS_VERTEX_SUPPORTED | SHADER_CAPS_PIXEL_SUPPORTED @@ -313,6 +314,9 @@ bool CWGPURenderLib::InitAPI(const ShaderAPIParams& params) if(wgpuAdapterHasFeature(m_rhiAdapter, WGPUFeatureName_MultiDrawIndirect)) requiredFeatures.append(WGPUFeatureName_MultiDrawIndirect); + if (wgpuAdapterHasFeature(m_rhiAdapter, WGPUFeatureName_IndirectFirstInstance)) + requiredFeatures.append(WGPUFeatureName_IndirectFirstInstance); + //requiredFeatures.append(WGPUFeatureName_BGRA8UnormStorage); //requiredFeatures.append(WGPUFeatureName_SurfaceCapabilities); requiredFeatures.append(WGPUFeatureName_Norm16TextureFormats); @@ -343,6 +347,12 @@ bool CWGPURenderLib::InitAPI(const ShaderAPIParams& params) } m_deviceQueue = wgpuDeviceGetQueue(m_rhiDevice); + + // multi-draw is only reported when indirect commands may also use firstInstance + caps.multiDrawIndirectSupport = wgpuDeviceHasFeature(m_rhiDevice, WGPUFeatureName_MultiDrawIndirect) + && wgpuDeviceHasFeature(m_rhiDevice, WGPUFeatureName_IndirectFirstInstance); + caps.multiDrawIndexedIndirectSupport = caps.multiDrawIndirectSupport; + caps.multiDrawIndirectCountSupport = caps.multiDrawIndirectSupport; } constexpr int jobQueueSize = 1024; diff --git a/core/materialsystem1/Renderers/WGPU/WGPURenderPassRecorder.cpp b/core/materialsystem1/Renderers/WGPU/WGPURenderPassRecorder.cpp index 4c6607aa5..78a11b2ce 100644 --- a/core/materialsystem1/Renderers/WGPU/WGPURenderPassRecorder.cpp +++ b/core/materialsystem1/Renderers/WGPU/WGPURenderPassRecorder.cpp @@ -140,7 +140,7 @@ void CWGPURenderPassRecorder::MultiDrawIndirect(IGPUBuffer* indirectBuffer, int { CWGPUBuffer* indirectBufferImpl = static_cast(indirectBuffer); CWGPUBuffer* drawCountBufferImpl = static_cast(drawCountBuffer); - wgpuRenderPassEncoderMultiDrawIndirect(m_rhiRenderPassEncoder, indirectBufferImpl->GetWGPUBuffer(), indirectOffset, maxDrawCount, drawCountBufferImpl->GetWGPUBuffer(), drawCountBufferOffset); + wgpuRenderPassEncoderMultiDrawIndirect(m_rhiRenderPassEncoder, indirectBufferImpl->GetWGPUBuffer(), indirectOffset, maxDrawCount, drawCountBufferImpl ? drawCountBufferImpl->GetWGPUBuffer() : nullptr, drawCountBufferOffset); ShaderAPIStats& stats = CWGPURenderAPI::Instance.GetStatsMutable(); Atomic::Increment(stats.indirectDrawCount); @@ -150,7 +150,7 @@ void CWGPURenderPassRecorder::MultiDrawIndexedIndirect(IGPUBuffer* indirectBuffe { CWGPUBuffer* indirectBufferImpl = static_cast(indirectBuffer); CWGPUBuffer* drawCountBufferImpl = static_cast(drawCountBuffer); - wgpuRenderPassEncoderMultiDrawIndexedIndirect(m_rhiRenderPassEncoder, indirectBufferImpl->GetWGPUBuffer(), indirectOffset, maxDrawCount, drawCountBufferImpl->GetWGPUBuffer(), drawCountBufferOffset); + wgpuRenderPassEncoderMultiDrawIndexedIndirect(m_rhiRenderPassEncoder, indirectBufferImpl->GetWGPUBuffer(), indirectOffset, maxDrawCount, drawCountBufferImpl ? drawCountBufferImpl->GetWGPUBuffer() : nullptr, drawCountBufferOffset); ShaderAPIStats& stats = CWGPURenderAPI::Instance.GetStatsMutable(); Atomic::Increment(stats.indirectDrawCount); diff --git a/docs/rendering_grim_overview.md b/docs/rendering_grim_overview.md index 10487709d..4c70a5b16 100644 --- a/docs/rendering_grim_overview.md +++ b/docs/rendering_grim_overview.md @@ -3,7 +3,8 @@ **GRIM — GPU-driven Rendering and Instance Manager** (`grimLib`). It keeps per-instance data in GPU buffers and renders large numbers of instanced meshes. Filtering, visibility culling, LOD selection and the building of indirect draw commands all run in compute shaders. The CPU only uploads -changed data and records one `DrawIndexedIndirect` per mesh batch. +changed data and records indirect draws: one multi-draw per run of mesh batches when the RHI +supports it, otherwise one `DrawIndexedIndirect` per batch. | File | Contents | |---|---| @@ -193,13 +194,35 @@ renderer.Draw(renderState, RenderPassContext(rendPassRecorder, nullptr)); invocations with `instanceCount > 0`. Until then every invocation is drawn; `Draw` waits for the readback if it is still in flight. - `Draw` skips inactive invocations, hidden archetypes and archetypes with no instances. It groups - the rest by material, vertex format and topology, sets up each material pipeline once and issues - `DrawIndexedIndirect` per batch. `RenderPassContext::beforeMaterialSetup` may replace or drop + the rest by material, vertex format and topology, sets up each material pipeline once and draws + the batches of the group (see [Multi-draw](#multi-draw)). `RenderPassContext::beforeMaterialSetup` may replace or drop materials (e.g. for a depth pass); `GRIMRenderState::drawCallMaterialGroupByFlags` selects whether grouping uses the original or the replaced material. - Group masks: an instance is drawn when `(mask & include) != 0` and `(mask & exclude) == 0`, with `include = renderState.groupMaskInclude & settings.groupMaskInclude` and exclude combined with OR. +### Multi-draw + +The indirect command index of a batch (`GPUIndexedBatch::cmdIdx`) is the index of its draw info. +The draw infos of an archetype are allocated as one contiguous range +(`AllocDrawInfoRange` / `FreeDrawInfoRange`), sorted by material, topology and LOD. Batches of an +archetype with the same material therefore have adjacent commands. + +`Draw` walks the commands of each material group in ascending order and binds the vertex and index +buffers once per archetype. Adjacent commands of the same archetype form a run that is drawn with +one `MultiDrawIndexedIndirect` (without a draw count buffer). A run may also cover commands of +other groups that lie between, but only if the readback (`PostPrepareDraw`) confirmed that they have +`instanceCount == 0`, so they draw nothing. Without readback results (software mode, failed +readback), runs contain only commands of the group. + +- Multi-draw is used when `ShaderAPICapabilities::multiDrawIndexedIndirectSupport` is set: D3D12, + Vulkan with the `multiDrawIndirect` and `drawIndirectFirstInstance` device features, WebGPU with + the `multi-draw-indirect` feature. D3D11 always draws commands one by one. +- `grim_dbgMultiDrawOff 1` draws every command separately. +- Archetypes have their own vertex and index buffers, so a multi-draw never crosses archetypes. +- Commands of archetypes created after `PrepareDraw` are not in the indirect buffer of that render + state and are not drawn until the next `PrepareDraw`. + ### Draw settings and software mode `GRIMDrawSettings` (`Get/SetDrawSettings`): group masks, `overrideLodIdx` (`-1` — by distance), @@ -286,10 +309,9 @@ Debug UI: "ENGINE/RENDER/INSTANCE MANAGER DEBUG UI" menu (`DemoInstManagerDebugD with per-archetype visibility, highlighting and details (LODs, batches, materials, vertex buffers). Available when `GRIM_INSTANCES_DEBUG_ENABLED` (not in `Profile` / `Retail`). - Cheat ConVars: `grim_dbgSoftwareMode`, `grim_dbgOnlyMaterial ` (draw only one material), - `grim_dbgLogArchetypes`, `grim_dbgValidate`. + `grim_dbgLogArchetypes`, `grim_dbgValidate`, `grim_dbgMultiDrawOff`. - With device validation enabled, every indirect draw gets a debug marker with the archetype, - material, LOD and instance count. -- Multi-draw indirect (`GRIM_MULTI_DRAW_SUPPORT`) is in development and disabled. + material, first LOD, number of commands and instance count. ## Limits diff --git a/public/materialsystem1/renderers/IGPUCommandRecorder.h b/public/materialsystem1/renderers/IGPUCommandRecorder.h index 68c805b94..1f9511298 100644 --- a/public/materialsystem1/renderers/IGPUCommandRecorder.h +++ b/public/materialsystem1/renderers/IGPUCommandRecorder.h @@ -89,6 +89,9 @@ class IGPURenderPassRecorder : public RefCountedObject virtual void DrawIndirect(IGPUBuffer* indirectBuffer, int indirectOffset) = 0; virtual void DrawIndexedIndirect(IGPUBuffer* indirectBuffer, int indirectOffset) = 0; + // Draws commands placed in indirectBuffer one after another. + // When drawCountBuffer is null exactly maxDrawCount commands are drawn (caps.multiDraw[Indexed]IndirectSupport), + // otherwise draw count is read from drawCountBuffer and clamped by maxDrawCount (caps.multiDrawIndirectCountSupport). virtual void MultiDrawIndirect(IGPUBuffer* indirectBuffer, int indirectOffset, int maxDrawCount, IGPUBuffer* drawCountBuffer, int drawCountBufferOffset) = 0; virtual void MultiDrawIndexedIndirect(IGPUBuffer* indirectBuffer, int indirectOffset, int maxDrawCount, IGPUBuffer* drawCountBuffer, int drawCountBufferOffset) = 0; diff --git a/public/materialsystem1/renderers/ShaderAPICaps.h b/public/materialsystem1/renderers/ShaderAPICaps.h index 7e5659608..b2be23487 100644 --- a/public/materialsystem1/renderers/ShaderAPICaps.h +++ b/public/materialsystem1/renderers/ShaderAPICaps.h @@ -43,8 +43,9 @@ struct ShaderAPICapabilities bool textureFormatsSupported[FORMAT_COUNT]{ false }; bool renderTargetFormatsSupported[FORMAT_COUNT]{ false }; - bool multiDrawIndirectSupport{ false }; - bool multiDrawIndexedIndirectSupport{ false }; + bool multiDrawIndirectSupport{ false }; // MultiDrawIndirect without draw count buffer + bool multiDrawIndexedIndirectSupport{ false }; // MultiDrawIndexedIndirect without draw count buffer + bool multiDrawIndirectCountSupport{ false }; // Multi draws with draw count buffer int minUniformBufferOffsetAlignment{ 1 }; int minStorageBufferOffsetAlignment{ 1 }; diff --git a/shared/grim/GrimBaseRenderer.cpp b/shared/grim/GrimBaseRenderer.cpp index 24b753b92..2898b7a44 100644 --- a/shared/grim/GrimBaseRenderer.cpp +++ b/shared/grim/GrimBaseRenderer.cpp @@ -19,19 +19,13 @@ #include "GrimInstanceAllocator.h" #include "materialsystem1/IMaterialSystem.h" -// in development -// #define GRIM_MULTI_DRAW_SUPPORT - using namespace Threading; DECLARE_CVAR(grim_dbgSoftwareMode, "0", nullptr, CV_CHEAT); DECLARE_CVAR(grim_dbgOnlyMaterial, "", nullptr, CV_CHEAT); DECLARE_CVAR(grim_dbgLogArchetypes, "0", nullptr, CV_CHEAT); DECLARE_CVAR(grim_dbgValidate, "0", nullptr, CV_CHEAT); - -#ifdef GRIM_MULTI_DRAW_SUPPORT -DECLARE_CVAR(grim_dbgMultiDrawOff, "0", nullptr, CV_CHEAT); -#endif +DECLARE_CVAR(grim_dbgMultiDrawOff, "0", "Draw every indirect command separately even if multi-draw is supported", CV_CHEAT); static constexpr char SHADERNAME_FILTER_INSTANCES[] = "InstanceFilter"; static constexpr char SHADERNAME_CULL_INSTANCES[] = "InstancesCull"; @@ -128,6 +122,8 @@ void GRIMBaseRenderer::Shutdown() m_pendingArchetypes.clear(true); m_pendingDeletion.clear(true); m_drawInfos.clear(true); + m_drawInfoFreeRanges.clear(true); + m_numDrawInfos = 0; m_drawBatchs.Clear(true); m_drawLodInfos.Clear(true); @@ -307,6 +303,8 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const CEqStudioGeom if (grim_dbgLogArchetypes.GetBool()) MsgInfo("GRIM: creating archetype %d (%s)\n", slot, archetypeInfo->name.ToCString()); + Array drawInfos(PP_SL); + int prevLod = -1; for (int i = 0; i < studio.numLodParams; i++) { @@ -364,7 +362,7 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const CEqStudioGeom drawInfo.batchIdx = newBatch; drawInfo.lodNumber = i; - m_drawBatchs[newBatch].cmdIdx = m_drawInfos.add(drawInfo); + drawInfos.append(drawInfo); hasBodyGroups = true; } } @@ -386,6 +384,8 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const CEqStudioGeom } prevLod = newLod; } + + AddArchetypeDrawInfos(drawInfos); } void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const GRIMArchetypeDesc& desc) @@ -436,6 +436,8 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const GRIMArchetype if (grim_dbgLogArchetypes.GetBool()) MsgInfo("GRIM: creating archetype %d (%s)\n", slot, archetypeInfo->name.ToCString()); + Array drawInfos(PP_SL); + int prevLod = -1; int numLods = 0; for (const GRIMArchetypeDesc::LodInfo& lodInfo : desc.lods) @@ -473,7 +475,7 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const GRIMArchetype drawInfo.batchIdx = newBatch; drawInfo.lodNumber = numLods; - m_drawBatchs[newBatch].cmdIdx = m_drawInfos.add(drawInfo); + drawInfos.append(drawInfo); hasBodyGroups = true; } @@ -496,6 +498,8 @@ void GRIMBaseRenderer::InitDrawArchetype(GRIMArchetype slot, const GRIMArchetype ++numLods; } + + AddArchetypeDrawInfos(drawInfos); } void GRIMBaseRenderer::DestroyDrawArchetype(GRIMArchetype archetype) @@ -572,7 +576,6 @@ void GRIMBaseRenderer::DestroyArchetypeData(GRIMArchetype slot) struct ItemInfo { enum EWhat { - DRAWINFO, BATCH, LODINFO }; @@ -583,13 +586,18 @@ void GRIMBaseRenderer::DestroyArchetypeData(GRIMArchetype slot) delItems.clear(); delItems.reserve(64); + // draw infos of archetype are allocated as single range + int firstCmdIdx = COM_INT_MAX; + int numCmds = 0; + const GPULodList lodList = m_drawLodsList[slot]; for (int lodIdx = lodList.firstLodInfo; lodIdx != -1; lodIdx = m_drawLodInfos[lodIdx].next) { const GPULodInfo lodInfo = m_drawLodInfos[lodIdx]; for (int batchIdx = lodInfo.firstBatch; batchIdx != -1; batchIdx = m_drawBatchs[batchIdx].next) { - delItems.appendEmplace(m_drawBatchs[batchIdx].cmdIdx, ItemInfo::DRAWINFO); + firstCmdIdx = min(firstCmdIdx, m_drawBatchs[batchIdx].cmdIdx); + ++numCmds; delItems.appendEmplace(batchIdx, ItemInfo::BATCH); } delItems.appendEmplace(lodIdx, ItemInfo::LODINFO); @@ -598,13 +606,13 @@ void GRIMBaseRenderer::DestroyArchetypeData(GRIMArchetype slot) if (grim_dbgLogArchetypes.GetBool()) MsgInfo("GRIM: freed archetype %d (%s)\n", slot, DbgGetArchetypeName(slot).ToCString()); + if (numCmds > 0) + FreeDrawInfoRange(firstCmdIdx, numCmds); + for (ItemInfo& item : delItems) { switch (item.what) { - case ItemInfo::DRAWINFO: - m_drawInfos.remove(item.index); - break; case ItemInfo::BATCH: m_drawBatchs.Remove(item.index); break; @@ -621,6 +629,115 @@ void GRIMBaseRenderer::DestroyArchetypeData(GRIMArchetype slot) } } +bool GRIMBaseRenderer::IsValidDrawInfo(int cmdIdx) const +{ + return m_drawInfos.inRange(cmdIdx) && m_drawInfos[cmdIdx].ownerArchetype != GRIM_INVALID_ARCHETYPE; +} + +void GRIMBaseRenderer::AddArchetypeDrawInfos(Array& drawInfos) +{ + if (drawInfos.isEmpty()) + return; + + // keep batches with same material and topology next to each other (in LOD order) + // so their indirect commands can be drawn with single multi-draw + arraySort(drawInfos, [](const DrawInfo& a, const DrawInfo& b) { + if (a.material.Ptr() != b.material.Ptr()) + return a.material.Ptr() < b.material.Ptr() ? -1 : 1; + if (a.primTopology != b.primTopology) + return static_cast(a.primTopology) - static_cast(b.primTopology); + if (a.lodNumber != b.lodNumber) + return a.lodNumber - b.lodNumber; + return a.batchIdx - b.batchIdx; + }); + + const int firstCmdIdx = AllocDrawInfoRange(drawInfos.numElem()); + for (int i = 0; i < drawInfos.numElem(); ++i) + { + const DrawInfo& drawInfo = drawInfos[i]; + m_drawInfos[firstCmdIdx + i] = drawInfo; + m_drawBatchs[drawInfo.batchIdx].cmdIdx = firstCmdIdx + i; + } +} + +int GRIMBaseRenderer::AllocDrawInfoRange(int count) +{ + ASSERT(count > 0); + m_numDrawInfos += count; + + for (int i = 0; i < m_drawInfoFreeRanges.numElem(); ++i) + { + DrawInfoRange& range = m_drawInfoFreeRanges[i]; + if (range.count < count) + continue; + + const int firstCmdIdx = range.first; + range.first += count; + range.count -= count; + if (range.count == 0) + m_drawInfoFreeRanges.removeIndex(i); + + return firstCmdIdx; + } + + const int firstCmdIdx = m_drawInfos.numElem(); + for (int i = 0; i < count; ++i) + m_drawInfos.append(DrawInfo{}); + + return firstCmdIdx; +} + +void GRIMBaseRenderer::FreeDrawInfoRange(int firstCmdIdx, int count) +{ + ASSERT(firstCmdIdx >= 0 && firstCmdIdx + count <= m_drawInfos.numElem()); + + for (int i = firstCmdIdx; i < firstCmdIdx + count; ++i) + { + ASSERT_MSG(IsValidDrawInfo(i), "Draw info %d is not allocated", i); + m_drawInfos[i] = DrawInfo{}; + } + m_numDrawInfos -= count; + + int rangeIdx = 0; + while (rangeIdx < m_drawInfoFreeRanges.numElem() && m_drawInfoFreeRanges[rangeIdx].first < firstCmdIdx) + ++rangeIdx; + m_drawInfoFreeRanges.insert(DrawInfoRange{ firstCmdIdx, count }, rangeIdx); + + // merge with adjacent free ranges + if (rangeIdx + 1 < m_drawInfoFreeRanges.numElem()) + { + DrawInfoRange& range = m_drawInfoFreeRanges[rangeIdx]; + const DrawInfoRange& nextRange = m_drawInfoFreeRanges[rangeIdx + 1]; + if (range.first + range.count == nextRange.first) + { + range.count += nextRange.count; + m_drawInfoFreeRanges.removeIndex(rangeIdx + 1); + } + } + + if (rangeIdx > 0) + { + DrawInfoRange& prevRange = m_drawInfoFreeRanges[rangeIdx - 1]; + const DrawInfoRange& range = m_drawInfoFreeRanges[rangeIdx]; + if (prevRange.first + prevRange.count == range.first) + { + prevRange.count += range.count; + m_drawInfoFreeRanges.removeIndex(rangeIdx); + } + } + + // free range at the end shrinks the draw info list + if (m_drawInfoFreeRanges.numElem()) + { + const DrawInfoRange lastRange = m_drawInfoFreeRanges.back(); + if (lastRange.first + lastRange.count == m_drawInfos.numElem()) + { + m_drawInfos.setNum(lastRange.first, false); + m_drawInfoFreeRanges.popBack(); + } + } +} + void GRIMBaseRenderer::DestroyPendingArchetypes() { if (!m_pendingDeletion.numElem()) @@ -1055,7 +1172,7 @@ void GRIMBaseRenderer::UpdateIndirectInstances_Software(IntermediateState& inter m_dbgStatsDrawnInstances += drawCmd.instanceCount; #endif - ASSERT_MSG(m_drawInfos(drawBatch.cmdIdx), "Draw archetype might not be present"); + ASSERT_MSG(IsValidDrawInfo(drawBatch.cmdIdx), "Draw archetype might not be present"); //drawCommands[drawBatch.cmdIdx] = drawCmd; cmdRecorder->WriteBuffer(drawInvocationsBuffer, &drawCmd, sizeof(drawCmd), sizeof(GPUDrawIndexedIndirectCmd) * drawBatch.cmdIdx); @@ -1087,7 +1204,7 @@ void GRIMBaseRenderer::PrepareDraw(IGPUCommandRecorder* cmdRecorder, GRIMRenderS if (maxNumberOfObjects == 0) return; - const BufferInfo drawInvocationsBufferInfo(sizeof(GPUDrawIndexedIndirectCmd), m_drawInfos.numSlots()); + const BufferInfo drawInvocationsBufferInfo(sizeof(GPUDrawIndexedIndirectCmd), m_drawInfos.numElem()); if(!renderState.drawInvocationsBuffer || renderState.drawInvocationsBuffer->GetSize() < drawInvocationsBufferInfo.GetBufferSize()) { renderState.drawInvocationsBuffer = g_renderAPI->CreateBuffer(drawInvocationsBufferInfo, BUFFERUSAGE_INDIRECT | BUFFERUSAGE_STORAGE | BUFFERUSAGE_COPY_DST | BUFFERUSAGE_COPY_SRC, "DrawInvocations"); @@ -1100,7 +1217,7 @@ void GRIMBaseRenderer::PrepareDraw(IGPUCommandRecorder* cmdRecorder, GRIMRenderS renderState.instanceIdsBuffer = g_renderAPI->CreateBuffer(instanceIdsBufferInfo, BUFFERUSAGE_VERTEX | BUFFERUSAGE_STORAGE | BUFFERUSAGE_COPY_DST, "InstanceIds"); } - renderState.activeDrawInvocations.resize(m_drawInfos.numSlots() + 1); + renderState.activeDrawInvocations.resize(m_drawInfos.numElem() + 1); renderState.visibleArchetypes.resize(m_drawLodsList.NumSlots() + 1); // everything is drawn by default @@ -1288,9 +1405,14 @@ void GRIMBaseRenderer::Draw(GRIMRenderState& renderState, const RenderPassContex } const ShaderAPICapabilities& caps = g_renderAPI->GetCaps(); + const bool useMultiDraw = caps.multiDrawIndexedIndirectSupport && !grim_dbgMultiDrawOff.GetBool(); + + // draw infos added after PrepareDraw have no commands in the indirect buffer + const int numDrawCmds = min(min(m_drawInfos.numElem(), renderState.activeDrawInvocations.numBits()), + static_cast(renderState.drawInvocationsBuffer->GetSize() / sizeof(GPUDrawIndexedIndirectCmd))); renderState.drawInfoLinkList.clear(); - renderState.drawInfoLinkList.reserve(m_drawInfos.numSlots()); + renderState.drawInfoLinkList.reserve(numDrawCmds + 1); renderState.drawInfoLinkList.append(GRIMRenderState::ListItm{}); // store end element in this array const bool validationOn = g_renderAPI->IsDeviceValidationActive(); @@ -1298,9 +1420,9 @@ void GRIMBaseRenderer::Draw(GRIMRenderState& renderState, const RenderPassContex thread_local Map drawInfosByMaterial(PP_SL); drawInfosByMaterial.clear(); - for (int i = 0; i < m_drawInfos.numSlots(); ++i) + for (int i = 0; i < numDrawCmds; ++i) { - if (!m_drawInfos(i)) + if (!IsValidDrawInfo(i)) continue; if (!renderState.activeDrawInvocations[i]) @@ -1348,13 +1470,6 @@ void GRIMBaseRenderer::Draw(GRIMRenderState& renderState, const RenderPassContex else materialId += reinterpret_cast(material); materialId *= 31; -#ifdef GRIM_MULTI_DRAW_SUPPORT - if (caps.multiDrawIndexedIndirectSupport && !grim_dbgMultiDrawOff.GetBool()) - { - materialId += drawInfo.ownerArchetype; - } - else -#endif { const ArchetypeInfo& archetypeInfo = drawInfo.archetypeInfo.Ref(); materialId += archetypeInfo.meshInstFormat.formatId; @@ -1371,58 +1486,76 @@ void GRIMBaseRenderer::Draw(GRIMRenderState& renderState, const RenderPassContex *it = renderState.drawInfoLinkList.append(GRIMRenderState::ListItm{ (uint16)i, (uint16)*it }); // link to last element } + thread_local Array groupCmds(PP_SL); + renderPassCtx.recorder->DbgPushGroup("GRIMDraw"); int numDrawCalls = 0; for (auto it = drawInfosByMaterial.begin(); !it.atEnd(); ++it) { - GRIMRenderState::ListItm litem = renderState.drawInfoLinkList[*it]; + // the list is linked from the last added draw info, so collect it in ascending command order + groupCmds.clear(); + for (GRIMRenderState::ListItm litem = renderState.drawInfoLinkList[*it]; litem.id != USHRT_MAX; litem = renderState.drawInfoLinkList[litem.next]) + groupCmds.append(litem.id); - const DrawInfo& setupDrawInfo = m_drawInfos[litem.id]; + const int numGroupCmds = groupCmds.numElem(); + for (int i = 0; i < numGroupCmds / 2; ++i) + QuickSwap(groupCmds[i], groupCmds[numGroupCmds - 1 - i]); + + // pipeline is set up from the list head (the last added draw info) + const DrawInfo& setupDrawInfo = m_drawInfos[groupCmds[numGroupCmds - 1]]; const ArchetypeInfo& setupArchetypeInfo = setupDrawInfo.archetypeInfo.Ref(); if (!g_matSystem->SetupMaterialPipeline(setupDrawInfo.material, nullptr, setupDrawInfo.primTopology, setupArchetypeInfo.meshInstFormat, renderPassCtx, this)) continue; -#ifdef GRIM_MULTI_DRAW_SUPPORT - if (caps.multiDrawIndexedIndirectSupport && !grim_dbgMultiDrawOff.GetBool()) + + const ArchetypeInfo* boundArchetypeInfo = nullptr; + for (int i = 0; i < numGroupCmds; ) { - for (int vsi = 0; vsi < setupArchetypeInfo.vertexBuffers.numElem(); ++vsi) - renderPassCtx.recorder->SetVertexBuffer(vsi, (setupArchetypeInfo.instanceStreamId == vsi) ? renderState.instanceIdsBuffer : setupArchetypeInfo.vertexBuffers[vsi]); - renderPassCtx.recorder->SetIndexBuffer(setupArchetypeInfo.indexBuffer, setupArchetypeInfo.indexFormat); + const int firstCmdIdx = groupCmds[i]; + const DrawInfo& drawInfo = m_drawInfos[firstCmdIdx]; + const ArchetypeInfo& archetypeInfo = drawInfo.archetypeInfo.Ref(); - for (; litem.id != USHRT_MAX; litem = renderState.drawInfoLinkList[litem.next]) + // Commands of one archetype are contiguous, so following commands of the group can join the multi-draw. + // Commands between them belong to other groups and may only be included + // when the readback confirmed they have no instances to draw. + int lastCmdIdx = firstCmdIdx; + for (++i; useMultiDraw && i < numGroupCmds; ++i) { - const DrawInfo& drawInfo = m_drawInfos[litem.id]; - ASSERT_MSG(drawInfo.archetypeInfo == setupDrawInfo.archetypeInfo, "Mismatching archetype for draw infos"); + const int nextCmdIdx = groupCmds[i]; + if (m_drawInfos[nextCmdIdx].ownerArchetype != drawInfo.ownerArchetype) + break; -#ifdef GRIM_INSTANCES_DEBUG_ENABLED - if (validationOn) - renderPassCtx.recorder->DbgAddMarker(EqString::Format("draw arch %d (mtl %s) (lod %d) (cnt %d)", drawInfo.ownerArchetype, drawInfo.material->GetName(), drawInfo.lodNumber, m_instAllocator.GetInstanceCount(drawInfo.ownerArchetype))); -#endif + bool emptyGap = true; + for (int j = lastCmdIdx + 1; j < nextCmdIdx && emptyGap; ++j) + emptyGap = !renderState.activeDrawInvocations[j]; - renderPassCtx.recorder->DrawIndexedIndirect(renderState.drawInvocationsBuffer, sizeof(GPUDrawIndexedIndirectCmd) * m_drawBatchs[drawInfo.batchIdx].cmdIdx); - ++numDrawCalls; + if (!emptyGap) + break; + + lastCmdIdx = nextCmdIdx; } - } - else -#endif - { - for (; litem.id != USHRT_MAX; litem = renderState.drawInfoLinkList[litem.next]) - { - const DrawInfo& drawInfo = m_drawInfos[litem.id]; - const ArchetypeInfo& archetypeInfo = drawInfo.archetypeInfo.Ref(); + if (boundArchetypeInfo != &archetypeInfo) + { for (int vsi = 0; vsi < archetypeInfo.vertexBuffers.numElem(); ++vsi) renderPassCtx.recorder->SetVertexBuffer(vsi, (archetypeInfo.instanceStreamId == vsi) ? renderState.instanceIdsBuffer : archetypeInfo.vertexBuffers[vsi]); renderPassCtx.recorder->SetIndexBuffer(archetypeInfo.indexBuffer, archetypeInfo.indexFormat); + boundArchetypeInfo = &archetypeInfo; + } + + const int numCmds = lastCmdIdx - firstCmdIdx + 1; + const int cmdOffset = static_cast(sizeof(GPUDrawIndexedIndirectCmd)) * firstCmdIdx; #ifdef GRIM_INSTANCES_DEBUG_ENABLED - if (validationOn) - renderPassCtx.recorder->DbgAddMarker(EqString::Format("draw arch %d (mtl %s) (lod %d) (cnt %d)", drawInfo.ownerArchetype, drawInfo.material->GetName(), drawInfo.lodNumber, m_instAllocator.GetInstanceCount(drawInfo.ownerArchetype))); + if (validationOn) + renderPassCtx.recorder->DbgAddMarker(EqString::Format("draw arch %d (mtl %s) (lod %d) (cmds %d) (cnt %d)", drawInfo.ownerArchetype, drawInfo.material->GetName(), drawInfo.lodNumber, numCmds, m_instAllocator.GetInstanceCount(drawInfo.ownerArchetype))); #endif - renderPassCtx.recorder->DrawIndexedIndirect(renderState.drawInvocationsBuffer, sizeof(GPUDrawIndexedIndirectCmd) * m_drawBatchs[drawInfo.batchIdx].cmdIdx); - ++numDrawCalls; - } + if (numCmds > 1) + renderPassCtx.recorder->MultiDrawIndexedIndirect(renderState.drawInvocationsBuffer, cmdOffset, numCmds, nullptr, 0); + else + renderPassCtx.recorder->DrawIndexedIndirect(renderState.drawInvocationsBuffer, cmdOffset); + ++numDrawCalls; } } renderPassCtx.recorder->DbgPopGroup(); @@ -1524,7 +1657,7 @@ void GRIMInstanceDebug::DrawUI(GRIMBaseRenderer& renderer) ImGui::TextDisabled("Draw calls: %d", renderer.m_dbgStatsDrawCalls); ImGui::TextDisabled("Drawn instances (Lods x Archetypes): %d", renderer.m_dbgStatsDrawnInstances); - ImGui::TextDisabled(" %d draw infos: %.2f KB", renderer.m_drawInfos.numElem(), memBytesToKB(renderer.m_drawInfos.numSlots() * sizeof(renderer.m_drawInfos[0]))); + ImGui::TextDisabled(" %d draw infos: %.2f KB", renderer.m_numDrawInfos, memBytesToKB(renderer.m_drawInfos.numElem() * sizeof(GRIMBaseRenderer::DrawInfo))); ImGui::TextDisabled(" %d batchs: %.2f KB", renderer.m_drawBatchs.NumElem(), memBytesToKB(renderer.m_drawBatchs.NumSlots() * sizeof(renderer.m_drawBatchs[0]))); ImGui::TextDisabled(" %d lod infos: %.2f KB", renderer.m_drawLodInfos.NumElem(), memBytesToKB(renderer.m_drawLodInfos.NumSlots() * sizeof(renderer.m_drawLodInfos[0]))); ImGui::TextDisabled(" %d lod lists: %.2f KB", renderer.m_drawLodsList.NumElem(), memBytesToKB(renderer.m_drawLodsList.NumSlots() * sizeof(renderer.m_drawLodsList[0]))); diff --git a/shared/grim/GrimBaseRenderer.h b/shared/grim/GrimBaseRenderer.h index a1e6258a5..c38a3d2f9 100644 --- a/shared/grim/GrimBaseRenderer.h +++ b/shared/grim/GrimBaseRenderer.h @@ -128,6 +128,11 @@ class GRIMBaseRenderer : public IShaderMeshInstanceProvider void DestroyPendingArchetypes(); void DestroyArchetypeData(GRIMArchetype slot); + bool IsValidDrawInfo(int cmdIdx) const; + void AddArchetypeDrawInfos(Array& drawInfos); + int AllocDrawInfoRange(int count); + void FreeDrawInfoRange(int firstCmdIdx, int count); + struct PendingDesc { enum Type { @@ -152,8 +157,19 @@ class GRIMBaseRenderer : public IShaderMeshInstanceProvider Array m_pendingArchetypes{ PP_SL }; Array m_pendingDeletion{ PP_SL }; + struct DrawInfoRange + { + int first{ 0 }; + int count{ 0 }; + }; + GRIMBaseInstanceAllocator& m_instAllocator; - SlottedArray m_drawInfos{ PP_SL }; + + // Draw info index is the indirect command index. Draw infos of an archetype occupy + // one contiguous range sorted by material and topology so they can be drawn with multi-draw. + Array m_drawInfos{ PP_SL }; + Array m_drawInfoFreeRanges{ PP_SL }; // sorted by first index + int m_numDrawInfos{ 0 }; Pool m_drawBatchs{ GRIMResource::BUFFER, "GPUIndexedBatch", PP_SL }; Pool m_drawLodInfos{ GRIMResource::BUFFER, "GPULodInfo", PP_SL }; Pool m_drawLodsList{ GRIMResource::BUFFER, "GPULodList", PP_SL }; From 3638a1318e5f3db69c9f0ef924312d565c2f00d4 Mon Sep 17 00:00:00 2001 From: SoapyMan Date: Wed, 4 Nov 2026 16:06:13 +0300 Subject: [PATCH 20/20] - ds: fix arraySort of fixed-size C rrays --- framework/ds/sort.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/ds/sort.h b/framework/ds/sort.h index 87528b609..596ad1fde 100644 --- a/framework/ds/sort.h +++ b/framework/ds/sort.h @@ -211,9 +211,9 @@ void arraySort(ARRAY_TYPE& arr, const CMP& comparator) } template -void arraySort(const T(&arr)[N], const CMP& comparator) +void arraySort(T(&arr)[N], const CMP& comparator) { - arraySort(arr.ptr(), arr.ptr() + arr.numElem(), comparator); + arraySort(arr, arr + N, comparator); } // -----------------------------------------------------------------