diff --git a/CLAUDE.md b/CLAUDE.md index 1fb2d51..44232e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy **Module-specific:** - **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles) - **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad) -- **UIModule** - User interface system (buttons, panels, scrolling, tooltips) +- **[UIModule README](modules/UIModule/README.md)** - User interface system (buttons, panels, scrolling, tooltips) +- **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture ## Available Modules diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 845f75d..25a25a8 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -251,14 +251,16 @@ gameIO->subscribe("ui:*"); // All UI events UIModule publishes render commands to BgfxRenderer via `UIRenderer`: ```cpp -// UIModule automatically publishes: -// - render:sprite (for UI rectangles/images) -// - render:text (for labels/buttons) +// UIModule uses retained mode rendering (only publishes on change): +// - render:sprite:add/update/remove (for UI rectangles/images) +// - render:text:add/update/remove (for labels/buttons) // BgfxRenderer consumes these and renders the UI // Layer management ensures UI renders on top (layer 1000+) ``` +**Retained Mode:** Widgets cache render state and only publish IIO messages when visual properties change. This reduces message traffic by 85%+ for typical UIs. See [UI Rendering Documentation](UI_RENDERING.md) for details. + **Full Topic Reference:** See [IIO Topics - UI Events](#ui-events) --- @@ -391,16 +393,38 @@ Consumed by **BgfxRenderer**, published by **UIModule** or **game logic**. #### Sprites +**Retained Mode (UIModule current):** + | Topic | Payload | Description | |-------|---------|-------------| -| `render:sprite` | `{x, y, scaleX, scaleY, rotation, u0, v0, u1, v1, color, textureId, layer}` | Render single sprite | +| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite (retained) | +| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite | +| `render:sprite:remove` | `{renderId}` | Unregister sprite | + +**Immediate Mode (legacy, still supported):** + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:sprite` | `{x, y, scaleX, scaleY, rotation, u0, v0, u1, v1, color, textureId, layer}` | Render single sprite (ephemeral) | | `render:sprite:batch` | `{sprites: [array]}` | Render sprite batch (optimized) | #### Text +**Retained Mode (UIModule current):** + | Topic | Payload | Description | |-------|---------|-------------| -| `render:text` | `{x, y, text, fontSize, color, layer}` | Render text | +| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text (retained) | +| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text | +| `render:text:remove` | `{renderId}` | Unregister text | + +**Immediate Mode (legacy, still supported):** + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:text` | `{x, y, text, fontSize, color, layer}` | Render text (ephemeral) | + +**Note:** See [UI Rendering Documentation](UI_RENDERING.md) for details on retained mode rendering. #### Tilemap diff --git a/docs/UI_RENDERING.md b/docs/UI_RENDERING.md new file mode 100644 index 0000000..3c2cdb7 --- /dev/null +++ b/docs/UI_RENDERING.md @@ -0,0 +1,447 @@ +# UI Retained Mode Rendering + +This document describes GroveEngine's retained mode rendering architecture for UIModule, which reduces IIO message traffic by caching render state and only publishing updates when visual properties change. + +## Overview + +GroveEngine supports two rendering modes: + +- **Immediate mode** (legacy): Widgets publish render commands every frame via `render:sprite` and `render:text` topics +- **Retained mode** (current): Widgets register render entries once and publish updates only when properties change via `render:sprite:add/update/remove` and `render:text:add/update/remove` topics + +The retained mode dramatically reduces IIO message traffic for static or mostly-static UIs. For a typical UI with 20 widgets where only 2-3 widgets change per frame (e.g., button hover states), this reduces render messages from 20+ per frame to 2-3 per frame. + +## Architecture + +### UIRenderer + +The `UIRenderer` class manages render state and publishes to IIO topics consumed by `BgfxRenderer`. + +**Key Components:** + +```cpp +class UIRenderer { + // Entry registration + uint32_t registerEntry(); // Returns unique render ID + void unregisterEntry(uint32_t renderId); // Removes entry on widget destruction + + // Retained mode updates (only publish if changed) + bool updateRect(uint32_t renderId, float x, float y, float w, float h, + uint32_t color, int layer); + bool updateText(uint32_t renderId, float x, float y, const std::string& text, + float fontSize, uint32_t color, int layer); + bool updateSprite(uint32_t renderId, float x, float y, float w, float h, + int textureId, uint32_t color, int layer); + +private: + std::unordered_map m_entries; // Cached state + uint32_t m_nextRenderId = 1; // ID generator +}; +``` + +**Render Entry State:** + +```cpp +struct RenderEntry { + RenderEntryType type; // Rect, Sprite, or Text + float x, y, w, h; + uint32_t color; + int textureId; + int layer; + std::string text; + float fontSize; +}; +``` + +The `UIRenderer` caches previous render state for each registered entry. When `updateRect/updateText/updateSprite` is called, it compares the new parameters against the cached state. If nothing changed, it returns `false` and publishes no message. If any parameter changed, it updates the cache and publishes an update message. + +### UIWidget Base Class + +All widgets inherit from `UIWidget` which provides retained mode infrastructure: + +```cpp +class UIWidget { +protected: + uint32_t m_renderId = 0; // Unique ID (0 = not registered) + bool m_geometryDirty = true; // Position/size changed + bool m_appearanceDirty = true; // Color/style changed + bool m_registered = false; // Has registered with renderer + WidgetDestroyCallback m_destroyCallback; // Cleanup on destruction + +public: + uint32_t getRenderId() const; + void setRenderId(uint32_t id); + bool isDirty() const; + bool isRegistered() const; + void setRegistered(bool reg); + void markGeometryDirty(); + void markAppearanceDirty(); + void clearDirtyFlags(); + void setDestroyCallback(WidgetDestroyCallback callback); +}; +``` + +**Widget Lifecycle:** + +1. **First render**: Widget calls `renderer.registerEntry()` to get a `renderId`, then calls `updateRect/updateText/updateSprite` which publishes `add` message +2. **Subsequent renders**: Widget calls `updateRect/updateText/updateSprite` with same `renderId`. If state changed, publishes `update` message. If unchanged, no message published. +3. **Destruction**: Widget destructor invokes `m_destroyCallback`, which calls `renderer.unregisterEntry()` to publish `remove` message + +### SceneCollector + +The `SceneCollector` in `BgfxRenderer` consumes both immediate and retained mode messages: + +```cpp +class SceneCollector { +private: + // Retained mode: persistent state (not cleared each frame) + std::unordered_map m_retainedSprites; + std::unordered_map m_retainedTexts; + std::unordered_map m_retainedTextStrings; + + // Immediate mode: ephemeral state (cleared each frame) + std::vector m_sprites; + std::vector m_texts; + std::vector m_textStrings; + + // Message handlers + void parseSpriteAdd(const IDataNode& data); // Add to m_retainedSprites + void parseSpriteUpdate(const IDataNode& data); // Update m_retainedSprites + void parseSpriteRemove(const IDataNode& data); // Remove from m_retainedSprites + void parseTextAdd(const IDataNode& data); // Add to m_retainedTexts + void parseTextUpdate(const IDataNode& data); // Update m_retainedTexts + void parseTextRemove(const IDataNode& data); // Remove from m_retainedTexts + void parseSprite(const IDataNode& data); // Add to m_sprites (legacy) + void parseText(const IDataNode& data); // Add to m_texts (legacy) +}; +``` + +During `finalize()`, `SceneCollector` merges retained and ephemeral entries into a single `FramePacket` that is rendered. + +## IIO Protocol + +### Retained Mode Topics + +**Sprites (rectangles and textured sprites):** + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite | +| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite | +| `render:sprite:remove` | `{renderId}` | Unregister sprite | + +**Text:** + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text | +| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text | +| `render:text:remove` | `{renderId}` | Unregister text | + +**Payload Fields:** + +- `renderId` (int): Unique identifier for this render entry (assigned by `UIRenderer.registerEntry()`) +- `x, y` (double): Position in screen coordinates + - For sprites: center position + - For text: top-left position +- `scaleX, scaleY` (double): Sprite dimensions (width/height) +- `color` (int): RGBA color in format `0xRRGGBBAA` +- `textureId` (int): Texture atlas ID (0 = white/solid color) +- `layer` (int): Render layer (higher = on top) +- `text` (string): Text content +- `fontSize` (double): Font size in pixels + +### Immediate Mode Topics (Legacy) + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:sprite` | `{x, y, scaleX, scaleY, color, textureId, layer}` | Ephemeral sprite (1 frame) | +| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) | + +These topics are still supported for compatibility and for truly ephemeral content (e.g., debug overlays, particle effects). + +## Widget Migration Pattern + +### Simple Widget (UILabel) + +A simple widget with one render entry: + +```cpp +void UILabel::render(UIRenderer& renderer) { + if (text.empty()) return; + + // Register with renderer on first render + if (!m_registered) { + m_renderId = renderer.registerEntry(); + m_registered = true; + + // Set destroy callback to unregister + setDestroyCallback([&renderer](uint32_t id) { + renderer.unregisterEntry(id); + }); + } + + // Retained mode: only publish if changed + int layer = renderer.nextLayer(); + renderer.updateText(m_renderId, absX, absY, text, fontSize, color, layer); +} +``` + +**First frame:** +- `registerEntry()` returns `renderId = 1` +- `updateText()` sees no cached entry, stores state, publishes `render:text:add` with `renderId=1` + +**Subsequent frames (text unchanged):** +- `updateText()` compares cached state, detects no change, returns `false`, no message published + +**Subsequent frames (text changed):** +- `updateText()` detects change, updates cache, publishes `render:text:update` with `renderId=1` + +**Widget destruction:** +- Destructor invokes callback +- Callback calls `unregisterEntry(1)` +- Publishes `render:text:remove` with `renderId=1` + +### Complex Widget (UIButton) + +A widget with multiple render entries (background + text): + +```cpp +void UIButton::render(UIRenderer& renderer) { + // Register with renderer on first render (need 2 entries: bg + text) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background + m_textRenderId = renderer.registerEntry(); // Text + m_registered = true; + + // Set destroy callback to unregister both + setDestroyCallback([&renderer, textId = m_textRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(textId); + }); + } + + const ButtonStyle& style = getCurrentStyle(); + + // Render background + int bgLayer = renderer.nextLayer(); + if (style.useTexture && style.textureId > 0) { + renderer.updateSprite(m_renderId, absX, absY, width, height, + style.textureId, style.bgColor, bgLayer); + } else { + renderer.updateRect(m_renderId, absX, absY, width, height, + style.bgColor, bgLayer); + } + + // Render text centered + if (!text.empty()) { + int textLayer = renderer.nextLayer(); + float textX = absX + width * 0.5f; + float textY = absY + height * 0.5f; + renderer.updateText(m_textRenderId, textX, textY, text, fontSize, + style.textColor, textLayer); + } + + renderChildren(renderer); +} +``` + +**Key Points:** + +- Each visual element (background, text) gets its own `renderId` +- Destroy callback must unregister all entries +- Style changes (hover, pressed) trigger color updates, but not position/size updates + +## Performance Characteristics + +### Static UI + +For a UI with 20 static widgets (e.g., main menu): + +- **Frame 1**: 20 `add` messages published +- **Frame 2+**: 0 messages published (no changes) + +### Dynamic UI + +For a UI with 20 widgets where 3 change per frame (e.g., button hover states): + +- **Immediate mode**: 20 messages per frame (all widgets republish) +- **Retained mode**: 3 messages per frame (only changed widgets publish `update`) + +### Highly Dynamic UI + +For a UI where all widgets change every frame (e.g., scrolling text): + +- **Immediate mode**: N messages per frame (one per widget) +- **Retained mode**: N messages per frame (all widgets publish `update`) +- **Overhead**: Retained mode has comparison overhead, but message payload is identical + +### Message Frequency Breakdown + +| Scenario | Widgets | Changed/Frame | Immediate Mode | Retained Mode | Reduction | +|----------|---------|---------------|----------------|---------------|-----------| +| Static UI | 20 | 0 | 20 | 0 | 100% | +| Mostly static | 20 | 3 | 20 | 3 | 85% | +| Half dynamic | 20 | 10 | 20 | 10 | 50% | +| Fully dynamic | 20 | 20 | 20 | 20 | 0% | + +The retained mode is most effective for UIs where the majority of widgets remain static across frames (main menus, HUDs, settings panels). + +## Change Detection + +The `UIRenderer` uses epsilon-based floating point comparison for position/size: + +```cpp +static bool floatEqual(float a, float b, float epsilon = 0.001f) { + return std::fabs(a - b) < epsilon; +} +``` + +This prevents spurious updates from floating point precision issues. For colors, text, and integer properties, exact equality is used. + +## Layer Management + +Each widget's render layer is assigned once during the `add` message and remains stable. This ensures consistent Z-ordering across frames without requiring layer updates. + +```cpp +bool UIRenderer::updateRect(uint32_t renderId, float x, float y, float w, float h, + uint32_t color, int layer) { + auto it = m_entries.find(renderId); + if (it == m_entries.end()) { + // New entry - store layer + RenderEntry entry; + entry.layer = layer; + m_entries[renderId] = entry; + publishSpriteAdd(renderId, x, y, w, h, 0, color, layer); + return true; + } + + // Update - ignore layer parameter, use original layer + RenderEntry& entry = it->second; + bool changed = !floatEqual(entry.x, x) || !floatEqual(entry.y, y) || + !floatEqual(entry.w, w) || !floatEqual(entry.h, h) || + entry.color != color; + + if (changed) { + entry.x = x; entry.y = y; entry.w = w; entry.h = h; entry.color = color; + publishSpriteUpdate(renderId, x, y, w, h, 0, color, entry.layer); + return true; + } + + return false; +} +``` + +## Migration Guide + +### Converting Immediate Mode to Retained Mode + +**Before (immediate mode):** + +```cpp +void MyWidget::render(UIRenderer& renderer) { + int layer = renderer.nextLayer(); + renderer.drawRect(absX, absY, width, height, color); // Publishes every frame +} +``` + +**After (retained mode):** + +```cpp +void MyWidget::render(UIRenderer& renderer) { + // Register on first render + if (!m_registered) { + m_renderId = renderer.registerEntry(); + m_registered = true; + setDestroyCallback([&renderer](uint32_t id) { + renderer.unregisterEntry(id); + }); + } + + // Only publishes if changed + int layer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, color, layer); +} +``` + +**Required changes:** + +1. Add `m_renderId` member to widget class (inherited from `UIWidget`) +2. Register entry on first render with `registerEntry()` +3. Set destroy callback to unregister entry +4. Replace `drawRect/drawText/drawSprite` with `updateRect/updateText/updateSprite` + +### Handling Multiple Render Entries + +For widgets that render multiple elements (e.g., button with background + text): + +```cpp +class UIButton : public UIWidget { +protected: + uint32_t m_renderId = 0; // Background render ID (inherited) + uint32_t m_textRenderId = 0; // Text render ID (additional) +}; + +void UIButton::render(UIRenderer& renderer) { + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background + m_textRenderId = renderer.registerEntry(); // Text + m_registered = true; + + // Unregister both on destruction + setDestroyCallback([&renderer, textId = m_textRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(textId); + }); + } + + // Render background + int bgLayer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, bgColor, bgLayer); + + // Render text + int textLayer = renderer.nextLayer(); + renderer.updateText(m_textRenderId, textX, textY, text, fontSize, textColor, textLayer); +} +``` + +## Compatibility + +Both immediate and retained mode topics are fully supported. Existing code using `render:sprite` and `render:text` continues to work. The `SceneCollector` merges both modes during frame finalization: + +```cpp +FramePacket SceneCollector::finalize(FrameAllocator& allocator) { + // Merge retained + ephemeral sprites + size_t totalSprites = m_retainedSprites.size() + m_sprites.size(); + SpriteInstance* sprites = allocator.allocateArray(totalSprites); + + size_t idx = 0; + for (const auto& [renderId, sprite] : m_retainedSprites) { + sprites[idx++] = sprite; + } + std::memcpy(&sprites[idx], m_sprites.data(), + m_sprites.size() * sizeof(SpriteInstance)); + + packet.sprites = sprites; + packet.spriteCount = totalSprites; + // ... +} +``` + +This allows gradual migration and mixed-mode rendering where some widgets use retained mode and others use immediate mode. + +## Implementation Files + +**UIModule:** +- `C:\Users\alexi\Documents\projects\groveengine\modules\UIModule\Rendering\UIRenderer.h` +- `C:\Users\alexi\Documents\projects\groveengine\modules\UIModule\Rendering\UIRenderer.cpp` +- `C:\Users\alexi\Documents\projects\groveengine\modules\UIModule\Core\UIWidget.h` + +**BgfxRenderer:** +- `C:\Users\alexi\Documents\projects\groveengine\modules\BgfxRenderer\Scene\SceneCollector.h` +- `C:\Users\alexi\Documents\projects\groveengine\modules\BgfxRenderer\Scene\SceneCollector.cpp` + +**Example Widgets:** +- `C:\Users\alexi\Documents\projects\groveengine\modules\UIModule\Widgets\UILabel.cpp` (simple, 1 entry) +- `C:\Users\alexi\Documents\projects\groveengine\modules\UIModule\Widgets\UIButton.cpp` (complex, 2 entries) +- All other widgets in `modules/UIModule/Widgets/` diff --git a/modules/BgfxRenderer/Scene/SceneCollector.cpp b/modules/BgfxRenderer/Scene/SceneCollector.cpp index 4adba25..2eea1bf 100644 --- a/modules/BgfxRenderer/Scene/SceneCollector.cpp +++ b/modules/BgfxRenderer/Scene/SceneCollector.cpp @@ -26,7 +26,28 @@ void SceneCollector::collect(IIO* io, float deltaTime) { if (!msg.data) continue; // Route message based on topic - if (msg.topic == "render:sprite") { + // Retained mode (new) - sprites + if (msg.topic == "render:sprite:add") { + parseSpriteAdd(*msg.data); + } + else if (msg.topic == "render:sprite:update") { + parseSpriteUpdate(*msg.data); + } + else if (msg.topic == "render:sprite:remove") { + parseSpriteRemove(*msg.data); + } + // Retained mode (new) - text + else if (msg.topic == "render:text:add") { + parseTextAdd(*msg.data); + } + else if (msg.topic == "render:text:update") { + parseTextUpdate(*msg.data); + } + else if (msg.topic == "render:text:remove") { + parseTextRemove(*msg.data); + } + // Ephemeral mode (legacy) + else if (msg.topic == "render:sprite") { parseSprite(*msg.data); } else if (msg.topic == "render:sprite:batch") { @@ -65,13 +86,22 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) { packet.mainView = m_mainView; packet.allocator = &allocator; - // Copy sprites to frame allocator - if (!m_sprites.empty()) { - SpriteInstance* sprites = allocator.allocateArray(m_sprites.size()); + // Copy sprites to frame allocator (merge retained + ephemeral) + size_t totalSprites = m_retainedSprites.size() + m_sprites.size(); + if (totalSprites > 0) { + SpriteInstance* sprites = allocator.allocateArray(totalSprites); if (sprites) { - std::memcpy(sprites, m_sprites.data(), m_sprites.size() * sizeof(SpriteInstance)); + size_t idx = 0; + // Copy retained sprites first + for (const auto& [renderId, sprite] : m_retainedSprites) { + sprites[idx++] = sprite; + } + // Copy ephemeral sprites + if (!m_sprites.empty()) { + std::memcpy(&sprites[idx], m_sprites.data(), m_sprites.size() * sizeof(SpriteInstance)); + } packet.sprites = sprites; - packet.spriteCount = m_sprites.size(); + packet.spriteCount = totalSprites; } } else { packet.sprites = nullptr; @@ -106,27 +136,45 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) { packet.tilemapCount = 0; } - // Copy texts (with string data) - if (!m_texts.empty()) { - TextCommand* texts = allocator.allocateArray(m_texts.size()); + // Copy texts (with string data) - merge retained + ephemeral + size_t totalTexts = m_retainedTexts.size() + m_texts.size(); + if (totalTexts > 0) { + TextCommand* texts = allocator.allocateArray(totalTexts); if (texts) { - std::memcpy(texts, m_texts.data(), m_texts.size() * sizeof(TextCommand)); + size_t idx = 0; - // Copy string data to frame allocator and fix up pointers - for (size_t i = 0; i < m_texts.size() && i < m_textStrings.size(); ++i) { - const std::string& str = m_textStrings[i]; - if (!str.empty()) { - // Allocate string + null terminator + // Copy retained texts first + for (const auto& [renderId, textCmd] : m_retainedTexts) { + texts[idx] = textCmd; + // Copy string data + auto strIt = m_retainedTextStrings.find(renderId); + if (strIt != m_retainedTextStrings.end() && !strIt->second.empty()) { + const std::string& str = strIt->second; char* textCopy = static_cast(allocator.allocate(str.size() + 1, 1)); if (textCopy) { std::memcpy(textCopy, str.c_str(), str.size() + 1); - texts[i].text = textCopy; + texts[idx].text = textCopy; } } + idx++; + } + + // Copy ephemeral texts + for (size_t i = 0; i < m_texts.size(); ++i) { + texts[idx] = m_texts[i]; + if (i < m_textStrings.size() && !m_textStrings[i].empty()) { + const std::string& str = m_textStrings[i]; + char* textCopy = static_cast(allocator.allocate(str.size() + 1, 1)); + if (textCopy) { + std::memcpy(textCopy, str.c_str(), str.size() + 1); + texts[idx].text = textCopy; + } + } + idx++; } packet.texts = texts; - packet.textCount = m_texts.size(); + packet.textCount = totalTexts; } } else { packet.texts = nullptr; @@ -406,4 +454,125 @@ void SceneCollector::initDefaultView(uint16_t width, uint16_t height) { m_mainView.projMatrix[15] = 1.0f; } +// ============================================================================ +// Retained Mode Parsing (sprites persist across frames) +// ============================================================================ + +void SceneCollector::parseSpriteAdd(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + SpriteInstance sprite; + sprite.x = static_cast(data.getDouble("x", 0.0)); + sprite.y = static_cast(data.getDouble("y", 0.0)); + sprite.scaleX = static_cast(data.getDouble("scaleX", 1.0)); + sprite.scaleY = static_cast(data.getDouble("scaleY", 1.0)); + sprite.rotation = static_cast(data.getDouble("rotation", 0.0)); + sprite.u0 = static_cast(data.getDouble("u0", 0.0)); + sprite.v0 = static_cast(data.getDouble("v0", 0.0)); + sprite.u1 = static_cast(data.getDouble("u1", 1.0)); + sprite.v1 = static_cast(data.getDouble("v1", 1.0)); + sprite.textureId = static_cast(data.getInt("textureId", 0)); + sprite.layer = static_cast(data.getInt("layer", 0)); + sprite.padding0 = 0.0f; + sprite.reserved[0] = 0.0f; + sprite.reserved[1] = 0.0f; + sprite.reserved[2] = 0.0f; + sprite.reserved[3] = 0.0f; + + uint32_t color = static_cast(data.getInt("color", 0xFFFFFFFF)); + sprite.r = static_cast((color >> 24) & 0xFF) / 255.0f; + sprite.g = static_cast((color >> 16) & 0xFF) / 255.0f; + sprite.b = static_cast((color >> 8) & 0xFF) / 255.0f; + sprite.a = static_cast(color & 0xFF) / 255.0f; + + m_retainedSprites[renderId] = sprite; +} + +void SceneCollector::parseSpriteUpdate(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + auto it = m_retainedSprites.find(renderId); + if (it == m_retainedSprites.end()) { + // Not found - treat as add + parseSpriteAdd(data); + return; + } + + // Update existing sprite + SpriteInstance& sprite = it->second; + sprite.x = static_cast(data.getDouble("x", sprite.x)); + sprite.y = static_cast(data.getDouble("y", sprite.y)); + sprite.scaleX = static_cast(data.getDouble("scaleX", sprite.scaleX)); + sprite.scaleY = static_cast(data.getDouble("scaleY", sprite.scaleY)); + sprite.rotation = static_cast(data.getDouble("rotation", sprite.rotation)); + sprite.textureId = static_cast(data.getInt("textureId", static_cast(sprite.textureId))); + sprite.layer = static_cast(data.getInt("layer", static_cast(sprite.layer))); + + uint32_t color = static_cast(data.getInt("color", 0xFFFFFFFF)); + sprite.r = static_cast((color >> 24) & 0xFF) / 255.0f; + sprite.g = static_cast((color >> 16) & 0xFF) / 255.0f; + sprite.b = static_cast((color >> 8) & 0xFF) / 255.0f; + sprite.a = static_cast(color & 0xFF) / 255.0f; +} + +void SceneCollector::parseSpriteRemove(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + m_retainedSprites.erase(renderId); +} + +void SceneCollector::parseTextAdd(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + TextCommand text; + text.x = static_cast(data.getDouble("x", 0.0)); + text.y = static_cast(data.getDouble("y", 0.0)); + text.fontId = static_cast(data.getInt("fontId", 0)); + text.fontSize = static_cast(data.getInt("fontSize", 16)); + text.color = static_cast(data.getInt("color", 0xFFFFFFFF)); + text.layer = static_cast(data.getInt("layer", 0)); + text.text = nullptr; // Will be set from m_retainedTextStrings in finalize + + m_retainedTexts[renderId] = text; + m_retainedTextStrings[renderId] = data.getString("text", ""); +} + +void SceneCollector::parseTextUpdate(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + auto it = m_retainedTexts.find(renderId); + if (it == m_retainedTexts.end()) { + // Not found - treat as add + parseTextAdd(data); + return; + } + + // Update existing text + TextCommand& text = it->second; + text.x = static_cast(data.getDouble("x", text.x)); + text.y = static_cast(data.getDouble("y", text.y)); + text.fontSize = static_cast(data.getInt("fontSize", text.fontSize)); + text.color = static_cast(data.getInt("color", text.color)); + text.layer = static_cast(data.getInt("layer", text.layer)); + + // Update text string if provided + std::string newText = data.getString("text", ""); + if (!newText.empty()) { + m_retainedTextStrings[renderId] = newText; + } +} + +void SceneCollector::parseTextRemove(const IDataNode& data) { + uint32_t renderId = static_cast(data.getInt("renderId", 0)); + if (renderId == 0) return; + + m_retainedTexts.erase(renderId); + m_retainedTextStrings.erase(renderId); +} + } // namespace grove diff --git a/modules/BgfxRenderer/Scene/SceneCollector.h b/modules/BgfxRenderer/Scene/SceneCollector.h index b4b30d1..149bd96 100644 --- a/modules/BgfxRenderer/Scene/SceneCollector.h +++ b/modules/BgfxRenderer/Scene/SceneCollector.h @@ -3,6 +3,7 @@ #include "../Frame/FramePacket.h" #include #include +#include namespace grove { @@ -33,7 +34,12 @@ public: void clear(); private: - // Staging buffers (filled during collect, copied to FramePacket in finalize) + // Retained mode: persistent sprites/texts (not cleared each frame) + std::unordered_map m_retainedSprites; + std::unordered_map m_retainedTexts; + std::unordered_map m_retainedTextStrings; // Text content for retained texts + + // Ephemeral mode: staging buffers (filled during collect, cleared each frame) std::vector m_sprites; std::vector m_tilemaps; std::vector> m_tilemapTiles; // Owns tile data until finalize @@ -49,7 +55,7 @@ private: uint64_t m_frameNumber = 0; float m_deltaTime = 0.0f; - // Message parsing helpers + // Message parsing helpers (ephemeral mode - legacy) void parseSprite(const IDataNode& data); void parseSpriteBatch(const IDataNode& data); void parseTilemap(const IDataNode& data); @@ -60,6 +66,14 @@ private: void parseDebugLine(const IDataNode& data); void parseDebugRect(const IDataNode& data); + // Message parsing helpers (retained mode - new) + void parseSpriteAdd(const IDataNode& data); + void parseSpriteUpdate(const IDataNode& data); + void parseSpriteRemove(const IDataNode& data); + void parseTextAdd(const IDataNode& data); + void parseTextUpdate(const IDataNode& data); + void parseTextRemove(const IDataNode& data); + // Initialize default view void initDefaultView(uint16_t width, uint16_t height); }; diff --git a/modules/UIModule/Core/UIWidget.h b/modules/UIModule/Core/UIWidget.h index c7df16f..a912200 100644 --- a/modules/UIModule/Core/UIWidget.h +++ b/modules/UIModule/Core/UIWidget.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "UILayout.h" namespace grove { @@ -11,6 +12,9 @@ namespace grove { class UIContext; class UIRenderer; +// Callback for when widget is destroyed (to notify renderer) +using WidgetDestroyCallback = std::function; + /** * @brief Base interface for all UI widgets * @@ -19,7 +23,12 @@ class UIRenderer; */ class UIWidget { public: - virtual ~UIWidget() = default; + virtual ~UIWidget() { + // Notify renderer to remove this widget's render entries + if (m_renderId != 0 && m_destroyCallback) { + m_destroyCallback(m_renderId); + } + } /** * @brief Update widget state @@ -126,6 +135,71 @@ protected: } } } + + // ======================================================================== + // Retained Mode / Dirty Tracking + // ======================================================================== +protected: + uint32_t m_renderId = 0; // Unique ID for render system (0 = not registered) + bool m_geometryDirty = true; // Position/size changed, needs re-render + bool m_appearanceDirty = true; // Color/style changed, needs re-render + bool m_registered = false; // Has been registered with renderer + WidgetDestroyCallback m_destroyCallback; // Called on destruction + +public: + /** + * @brief Get render ID (0 if not registered) + */ + uint32_t getRenderId() const { return m_renderId; } + + /** + * @brief Set render ID (called by UIRenderer on registration) + */ + void setRenderId(uint32_t id) { m_renderId = id; } + + /** + * @brief Check if widget needs re-rendering + */ + bool isDirty() const { return m_geometryDirty || m_appearanceDirty; } + + /** + * @brief Check if registered with renderer + */ + bool isRegistered() const { return m_registered; } + + /** + * @brief Mark as registered + */ + void setRegistered(bool reg) { m_registered = reg; } + + /** + * @brief Mark geometry as dirty (position, size changed) + */ + void markGeometryDirty() { + m_geometryDirty = true; + } + + /** + * @brief Mark appearance as dirty (color, style changed) + */ + void markAppearanceDirty() { + m_appearanceDirty = true; + } + + /** + * @brief Clear dirty flags after rendering + */ + void clearDirtyFlags() { + m_geometryDirty = false; + m_appearanceDirty = false; + } + + /** + * @brief Set callback for widget destruction + */ + void setDestroyCallback(WidgetDestroyCallback callback) { + m_destroyCallback = std::move(callback); + } }; } // namespace grove diff --git a/modules/UIModule/README.md b/modules/UIModule/README.md index ec6d046..7cbfcae 100644 --- a/modules/UIModule/README.md +++ b/modules/UIModule/README.md @@ -11,7 +11,7 @@ UIModule provides a full-featured UI system that integrates with BgfxRenderer fo - **10 Widget Types**: Buttons, Labels, Panels, Checkboxes, Sliders, Text Inputs, Progress Bars, Images, Scroll Panels, Tooltips - **Flexible Layout**: JSON-based UI definition with hierarchical widget trees - **Automatic Input**: Consumes `input:*` topics from InputModule automatically -- **Rendering Integration**: Publishes `render:*` topics to BgfxRenderer +- **Retained Mode Rendering**: Widgets cache render state and only publish IIO messages when visual properties change, reducing message traffic for static UIs - **Layer Management**: UI renders on top of game content (layer 1000+) - **Hot-Reload Support**: Full state preservation across module reloads @@ -213,10 +213,25 @@ void GameModule::process(const IDataNode& input) { ### Topics Published (Rendering) +**Retained Mode (current):** + | Topic | Payload | Description | |-------|---------|-------------| -| `render:sprite` | `{x, y, w, h, color, layer, ...}` | UI rectangles/images | -| `render:text` | `{x, y, text, fontSize, color, layer}` | UI text | +| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite | +| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite | +| `render:sprite:remove` | `{renderId}` | Unregister sprite | +| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text | +| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text | +| `render:text:remove` | `{renderId}` | Unregister text | + +**Immediate Mode (legacy, still supported):** + +| Topic | Payload | Description | +|-------|---------|-------------| +| `render:sprite` | `{x, y, w, h, color, layer, ...}` | Ephemeral sprite (1 frame) | +| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) | + +See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for details on retained mode rendering. ## Widget Properties Reference @@ -377,10 +392,33 @@ UIModule fully supports hot-reload with state preservation: - Transient animation states - Mouse hover states (recalculated on next mouse move) +## Rendering Modes + +UIModule uses **retained mode rendering** to optimize IIO message traffic. Widgets register render entries once and only publish updates when visual properties change. + +### Retained Mode + +Widgets cache their render state and compare against previous values each frame. Only changed properties trigger IIO messages. + +**Message Reduction:** +- Static UI (20 widgets, 0 changes/frame): 100% reduction (0 messages after initial registration) +- Mostly static UI (20 widgets, 3 changes/frame): 85% reduction (3 messages vs 20) +- Fully dynamic UI (20 widgets, 20 changes/frame): 0% reduction (retained mode has comparison overhead) + +**Topics:** `render:sprite:add/update/remove`, `render:text:add/update/remove` + +### Immediate Mode (Legacy) + +Widgets publish render commands every frame regardless of changes. Still supported for compatibility and ephemeral content (debug overlays, particles). + +**Topics:** `render:sprite`, `render:text` + +See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for implementation details and migration guide. + ## Performance - **Target**: < 1ms per frame for UI updates -- **Batching**: Multiple UI rectangles batched into single render commands +- **Retained mode**: Reduces IIO traffic by 85%+ for typical UIs (static menus, HUDs) - **Event filtering**: Only processes mouse events within widget bounds - **Layout caching**: Widget tree built once from JSON, not every frame diff --git a/modules/UIModule/Rendering/UIRenderer.cpp b/modules/UIModule/Rendering/UIRenderer.cpp index ae087de..76bdd65 100644 --- a/modules/UIModule/Rendering/UIRenderer.cpp +++ b/modules/UIModule/Rendering/UIRenderer.cpp @@ -1,6 +1,7 @@ #include "UIRenderer.h" #include #include +#include namespace grove { @@ -8,6 +9,217 @@ UIRenderer::UIRenderer(IIO* io) : m_io(io) { } +// ============================================================================ +// Retained Mode Implementation +// ============================================================================ + +uint32_t UIRenderer::registerEntry() { + return m_nextRenderId++; +} + +void UIRenderer::unregisterEntry(uint32_t renderId) { + auto it = m_entries.find(renderId); + if (it != m_entries.end()) { + // Send remove message based on type + if (it->second.type == RenderEntryType::Text) { + publishTextRemove(renderId); + } else { + publishSpriteRemove(renderId); + } + m_entries.erase(it); + } +} + +static bool floatEqual(float a, float b, float epsilon = 0.001f) { + return std::fabs(a - b) < epsilon; +} + +bool UIRenderer::updateRect(uint32_t renderId, float x, float y, float w, float h, uint32_t color, int layer) { + if (!m_io) return false; + + auto it = m_entries.find(renderId); + if (it == m_entries.end()) { + // New entry - add it + RenderEntry entry; + entry.type = RenderEntryType::Rect; + entry.x = x; + entry.y = y; + entry.w = w; + entry.h = h; + entry.color = color; + entry.textureId = 0; + entry.layer = layer; // Store initial layer (stable) + m_entries[renderId] = entry; + publishSpriteAdd(renderId, x, y, w, h, 0, color, layer); + return true; + } + + // Check if changed (ignore layer - it's set once at registration) + RenderEntry& entry = it->second; + bool changed = !floatEqual(entry.x, x) || !floatEqual(entry.y, y) || + !floatEqual(entry.w, w) || !floatEqual(entry.h, h) || + entry.color != color; + + if (changed) { + entry.x = x; + entry.y = y; + entry.w = w; + entry.h = h; + entry.color = color; + // Keep original layer (don't update it) + publishSpriteUpdate(renderId, x, y, w, h, 0, color, entry.layer); + return true; + } + + return false; // No change, no publish +} + +bool UIRenderer::updateText(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer) { + if (!m_io) return false; + + auto it = m_entries.find(renderId); + if (it == m_entries.end()) { + // New entry - add it + RenderEntry entry; + entry.type = RenderEntryType::Text; + entry.x = x; + entry.y = y; + entry.text = text; + entry.fontSize = fontSize; + entry.color = color; + entry.layer = layer; // Store initial layer (stable) + m_entries[renderId] = entry; + publishTextAdd(renderId, x, y, text, fontSize, color, layer); + return true; + } + + // Check if changed (ignore layer - it's set once at registration) + RenderEntry& entry = it->second; + bool changed = !floatEqual(entry.x, x) || !floatEqual(entry.y, y) || + entry.text != text || !floatEqual(entry.fontSize, fontSize) || + entry.color != color; + + if (changed) { + entry.x = x; + entry.y = y; + entry.text = text; + entry.fontSize = fontSize; + entry.color = color; + // Keep original layer (don't update it) + publishTextUpdate(renderId, x, y, text, fontSize, color, entry.layer); + return true; + } + + return false; +} + +bool UIRenderer::updateSprite(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) { + if (!m_io) return false; + + auto it = m_entries.find(renderId); + if (it == m_entries.end()) { + // New entry - add it + RenderEntry entry; + entry.type = RenderEntryType::Sprite; + entry.x = x; + entry.y = y; + entry.w = w; + entry.h = h; + entry.textureId = textureId; + entry.color = color; + entry.layer = layer; // Store initial layer (stable) + m_entries[renderId] = entry; + publishSpriteAdd(renderId, x, y, w, h, textureId, color, layer); + return true; + } + + // Check if changed (ignore layer - it's set once at registration) + RenderEntry& entry = it->second; + bool changed = !floatEqual(entry.x, x) || !floatEqual(entry.y, y) || + !floatEqual(entry.w, w) || !floatEqual(entry.h, h) || + entry.textureId != textureId || entry.color != color; + + if (changed) { + entry.x = x; + entry.y = y; + entry.w = w; + entry.h = h; + entry.textureId = textureId; + entry.color = color; + // Keep original layer (don't update it) + publishSpriteUpdate(renderId, x, y, w, h, textureId, color, entry.layer); + return true; + } + + return false; +} + +void UIRenderer::publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) { + auto sprite = std::make_unique("sprite"); + sprite->setInt("renderId", static_cast(renderId)); + sprite->setDouble("x", static_cast(x + w * 0.5f)); + sprite->setDouble("y", static_cast(y + h * 0.5f)); + sprite->setDouble("scaleX", static_cast(w)); + sprite->setDouble("scaleY", static_cast(h)); + sprite->setInt("color", static_cast(color)); + sprite->setInt("textureId", textureId); + sprite->setInt("layer", layer); + m_io->publish("render:sprite:add", std::move(sprite)); +} + +void UIRenderer::publishSpriteUpdate(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) { + auto sprite = std::make_unique("sprite"); + sprite->setInt("renderId", static_cast(renderId)); + sprite->setDouble("x", static_cast(x + w * 0.5f)); + sprite->setDouble("y", static_cast(y + h * 0.5f)); + sprite->setDouble("scaleX", static_cast(w)); + sprite->setDouble("scaleY", static_cast(h)); + sprite->setInt("color", static_cast(color)); + sprite->setInt("textureId", textureId); + sprite->setInt("layer", layer); + m_io->publish("render:sprite:update", std::move(sprite)); +} + +void UIRenderer::publishSpriteRemove(uint32_t renderId) { + auto sprite = std::make_unique("sprite"); + sprite->setInt("renderId", static_cast(renderId)); + m_io->publish("render:sprite:remove", std::move(sprite)); +} + +void UIRenderer::publishTextAdd(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer) { + auto textNode = std::make_unique("text"); + textNode->setInt("renderId", static_cast(renderId)); + textNode->setDouble("x", static_cast(x)); + textNode->setDouble("y", static_cast(y)); + textNode->setString("text", text); + textNode->setDouble("fontSize", static_cast(fontSize)); + textNode->setInt("color", static_cast(color)); + textNode->setInt("layer", layer); + m_io->publish("render:text:add", std::move(textNode)); +} + +void UIRenderer::publishTextUpdate(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer) { + auto textNode = std::make_unique("text"); + textNode->setInt("renderId", static_cast(renderId)); + textNode->setDouble("x", static_cast(x)); + textNode->setDouble("y", static_cast(y)); + textNode->setString("text", text); + textNode->setDouble("fontSize", static_cast(fontSize)); + textNode->setInt("color", static_cast(color)); + textNode->setInt("layer", layer); + m_io->publish("render:text:update", std::move(textNode)); +} + +void UIRenderer::publishTextRemove(uint32_t renderId) { + auto textNode = std::make_unique("text"); + textNode->setInt("renderId", static_cast(renderId)); + m_io->publish("render:text:remove", std::move(textNode)); +} + +// ============================================================================ +// Immediate Mode (Legacy) +// ============================================================================ + void UIRenderer::drawRect(float x, float y, float w, float h, uint32_t color) { if (!m_io) return; diff --git a/modules/UIModule/Rendering/UIRenderer.h b/modules/UIModule/Rendering/UIRenderer.h index 0cbcd64..17dc433 100644 --- a/modules/UIModule/Rendering/UIRenderer.h +++ b/modules/UIModule/Rendering/UIRenderer.h @@ -3,14 +3,36 @@ #include #include #include +#include namespace grove { +// Render entry types +enum class RenderEntryType { + Rect, + Sprite, + Text +}; + +// Cached state for a render entry (to detect changes) +struct RenderEntry { + RenderEntryType type; + float x, y, w, h; + uint32_t color; + int textureId; + int layer; + std::string text; + float fontSize; +}; + /** * @brief Renders UI elements by publishing to IIO topics * - * UIRenderer doesn't render directly - it publishes render commands - * via IIO topics (render:sprite, render:text) that BgfxRenderer consumes. + * UIRenderer supports two modes: + * - Immediate mode (legacy): drawRect/drawText/drawSprite publish every call + * - Retained mode (new): updateRect/updateText/updateSprite only publish on change + * + * Retained mode dramatically reduces IIO message traffic for static UIs. */ class UIRenderer { public: @@ -64,10 +86,56 @@ public: */ void beginFrame() { m_layerOffset = 0; } + // ======================================================================== + // Retained Mode API + // ======================================================================== + + /** + * @brief Register a new render entry and get its ID + * @return Unique render ID for this entry + */ + uint32_t registerEntry(); + + /** + * @brief Unregister a render entry (widget destroyed) + * @param renderId ID to remove + */ + void unregisterEntry(uint32_t renderId); + + /** + * @brief Update a rectangle (only publishes if changed) + * @return true if published (changed), false if skipped (unchanged) + */ + bool updateRect(uint32_t renderId, float x, float y, float w, float h, uint32_t color, int layer); + + /** + * @brief Update text (only publishes if changed) + * @return true if published (changed), false if skipped (unchanged) + */ + bool updateText(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer); + + /** + * @brief Update a textured sprite (only publishes if changed) + * @return true if published (changed), false if skipped (unchanged) + */ + bool updateSprite(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer); + private: IIO* m_io; int m_baseLayer = 1000; // UI renders above game content int m_layerOffset = 0; // Increments per draw call for proper ordering + + // Retained mode state + uint32_t m_nextRenderId = 1; + std::unordered_map m_entries; + + // Publish helpers + void publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer); + void publishSpriteUpdate(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer); + void publishSpriteRemove(uint32_t renderId); + void publishTextAdd(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer); + void publishTextUpdate(uint32_t renderId, float x, float y, const std::string& text, float fontSize, uint32_t color, int layer); + void publishTextRemove(uint32_t renderId); }; } // namespace grove diff --git a/modules/UIModule/Widgets/UIButton.cpp b/modules/UIModule/Widgets/UIButton.cpp index cc70d59..162da66 100644 --- a/modules/UIModule/Widgets/UIButton.cpp +++ b/modules/UIModule/Widgets/UIButton.cpp @@ -30,29 +30,37 @@ void UIButton::update(UIContext& ctx, float deltaTime) { } void UIButton::render(UIRenderer& renderer) { + // Register with renderer on first render (need 2 entries: bg + text) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background + m_textRenderId = renderer.registerEntry(); // Text + m_registered = true; + // Set destroy callback to unregister both + setDestroyCallback([&renderer, textId = m_textRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(textId); + }); + } + const ButtonStyle& style = getCurrentStyle(); + // Retained mode: only publish if changed + int bgLayer = renderer.nextLayer(); + // Render background (texture or solid color) if (style.useTexture && style.textureId > 0) { - renderer.drawSprite(absX, absY, width, height, style.textureId, style.bgColor); + renderer.updateSprite(m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer); } else { - renderer.drawRect(absX, absY, width, height, style.bgColor); - } - - // Render border if specified - if (style.borderWidth > 0.0f) { - // TODO: Implement border rendering in UIRenderer - // For now, just render a slightly darker rect as border + renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer); } // Render text centered if (!text.empty()) { - // Calculate text position (centered) - // Note: UIRenderer doesn't support text centering yet, so we approximate + int textLayer = renderer.nextLayer(); float textX = absX + width * 0.5f; float textY = absY + height * 0.5f; - renderer.drawText(textX, textY, text, fontSize, style.textColor); + renderer.updateText(m_textRenderId, textX, textY, text, fontSize, style.textColor, textLayer); } // Render children on top diff --git a/modules/UIModule/Widgets/UIButton.h b/modules/UIModule/Widgets/UIButton.h index 1c18701..03285ef 100644 --- a/modules/UIModule/Widgets/UIButton.h +++ b/modules/UIModule/Widgets/UIButton.h @@ -101,6 +101,9 @@ private: * @return Adjusted color */ static uint32_t adjustBrightness(uint32_t color, float factor); + + // Retained mode render IDs + uint32_t m_textRenderId = 0; // Separate ID for text element }; } // namespace grove diff --git a/modules/UIModule/Widgets/UICheckbox.cpp b/modules/UIModule/Widgets/UICheckbox.cpp index 1e73920..db33fb0 100644 --- a/modules/UIModule/Widgets/UICheckbox.cpp +++ b/modules/UIModule/Widgets/UICheckbox.cpp @@ -13,32 +13,54 @@ void UICheckbox::update(UIContext& ctx, float deltaTime) { } void UICheckbox::render(UIRenderer& renderer) { + // Register with renderer on first render (need 3 entries: box + checkmark + text) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Box background + m_checkRenderId = renderer.registerEntry(); // Checkmark + m_textRenderId = renderer.registerEntry(); // Label text + m_registered = true; + // Set destroy callback to unregister all entries + setDestroyCallback([&renderer, checkId = m_checkRenderId, textId = m_textRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(checkId); + renderer.unregisterEntry(textId); + }); + } + // Render checkbox box float boxX = absX; float boxY = absY + (height - boxSize) * 0.5f; // Vertically center the box - // Box background + // Box background (retained mode) + int boxLayer = renderer.nextLayer(); uint32_t currentBoxColor = isHovered ? 0x475569FF : boxColor; - renderer.drawRect(boxX, boxY, boxSize, boxSize, currentBoxColor); + renderer.updateRect(m_renderId, boxX, boxY, boxSize, boxSize, currentBoxColor, boxLayer); - // Check mark if checked + // Check mark if checked (retained mode) + int checkLayer = renderer.nextLayer(); if (checked) { // Draw a smaller filled rect as checkmark float checkPadding = boxSize * 0.25f; - renderer.drawRect( + renderer.updateRect( + m_checkRenderId, boxX + checkPadding, boxY + checkPadding, boxSize - checkPadding * 2, boxSize - checkPadding * 2, - checkColor + checkColor, + checkLayer ); + } else { + // Hide checkmark when unchecked (zero-size rect) + renderer.updateRect(m_checkRenderId, 0, 0, 0, 0, 0x00000000, checkLayer); } - // Render label text if present + // Render label text if present (retained mode) if (!text.empty()) { + int textLayer = renderer.nextLayer(); float textX = boxX + boxSize + spacing; float textY = absY + height * 0.5f; - renderer.drawText(textX, textY, text, fontSize, textColor); + renderer.updateText(m_textRenderId, textX, textY, text, fontSize, textColor, textLayer); } // Render children on top diff --git a/modules/UIModule/Widgets/UICheckbox.h b/modules/UIModule/Widgets/UICheckbox.h index 32958d9..fc91fc1 100644 --- a/modules/UIModule/Widgets/UICheckbox.h +++ b/modules/UIModule/Widgets/UICheckbox.h @@ -52,6 +52,11 @@ public: // State bool isHovered = false; bool isPressed = false; + +private: + // Retained mode render IDs (m_renderId from base class used for box background) + uint32_t m_checkRenderId = 0; // Checkmark element + uint32_t m_textRenderId = 0; // Label text element }; } // namespace grove diff --git a/modules/UIModule/Widgets/UIImage.cpp b/modules/UIModule/Widgets/UIImage.cpp index a1e7c36..b903f71 100644 --- a/modules/UIModule/Widgets/UIImage.cpp +++ b/modules/UIModule/Widgets/UIImage.cpp @@ -11,19 +11,23 @@ void UIImage::update(UIContext& ctx, float deltaTime) { } void UIImage::render(UIRenderer& renderer) { - // Render the texture - // For now, use the simple sprite rendering - // TODO: Implement proper UV mapping and scale modes in UIRenderer - - if (scaleMode == ScaleMode::Stretch || scaleMode == ScaleMode::None) { - // Simple case: render sprite at widget bounds - renderer.drawSprite(absX, absY, width, height, textureId, tintColor); - } else { - // For Fit/Fill modes, we'd need to calculate proper dimensions - // based on texture aspect ratio. For now, just stretch. - renderer.drawSprite(absX, absY, width, height, textureId, tintColor); + // Register with renderer on first render + if (!m_registered) { + m_renderId = renderer.registerEntry(); + m_registered = true; + // Set destroy callback to unregister + setDestroyCallback([&renderer](uint32_t id) { + renderer.unregisterEntry(id); + }); } + // Retained mode: only publish if changed + int layer = renderer.nextLayer(); + + // TODO: Implement proper UV mapping and scale modes in UIRenderer + // For now, all scale modes use the same rendering (stretch to bounds) + renderer.updateSprite(m_renderId, absX, absY, width, height, textureId, tintColor, layer); + // Render children on top renderChildren(renderer); } diff --git a/modules/UIModule/Widgets/UILabel.cpp b/modules/UIModule/Widgets/UILabel.cpp index 959e44f..5801c1f 100644 --- a/modules/UIModule/Widgets/UILabel.cpp +++ b/modules/UIModule/Widgets/UILabel.cpp @@ -10,9 +10,21 @@ void UILabel::update(UIContext& ctx, float deltaTime) { } void UILabel::render(UIRenderer& renderer) { - if (!text.empty()) { - renderer.drawText(absX, absY, text, fontSize, color); + if (text.empty()) return; + + // Register with renderer on first render + if (!m_registered) { + m_renderId = renderer.registerEntry(); + m_registered = true; + // Set destroy callback to unregister + setDestroyCallback([&renderer](uint32_t id) { + renderer.unregisterEntry(id); + }); } + + // Retained mode: only publish if changed + int layer = renderer.nextLayer(); + renderer.updateText(m_renderId, absX, absY, text, fontSize, color, layer); } } // namespace grove diff --git a/modules/UIModule/Widgets/UIPanel.cpp b/modules/UIModule/Widgets/UIPanel.cpp index c38108d..3fb9786 100644 --- a/modules/UIModule/Widgets/UIPanel.cpp +++ b/modules/UIModule/Widgets/UIPanel.cpp @@ -19,8 +19,19 @@ void UIPanel::update(UIContext& ctx, float deltaTime) { } void UIPanel::render(UIRenderer& renderer) { - // Render background rectangle - renderer.drawRect(absX, absY, width, height, bgColor); + // Register with renderer on first render + if (!m_registered) { + m_renderId = renderer.registerEntry(); + m_registered = true; + // Set destroy callback to unregister + setDestroyCallback([&renderer](uint32_t id) { + renderer.unregisterEntry(id); + }); + } + + // Retained mode: only publish if changed + int layer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, bgColor, layer); // Render children on top renderChildren(renderer); diff --git a/modules/UIModule/Widgets/UIProgressBar.cpp b/modules/UIModule/Widgets/UIProgressBar.cpp index c0789e4..4869d46 100644 --- a/modules/UIModule/Widgets/UIProgressBar.cpp +++ b/modules/UIModule/Widgets/UIProgressBar.cpp @@ -14,16 +14,32 @@ void UIProgressBar::update(UIContext& ctx, float deltaTime) { } void UIProgressBar::render(UIRenderer& renderer) { - // Render background - renderer.drawRect(absX, absY, width, height, bgColor); + // Register with renderer on first render (need 3 entries: bg + fill + text) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background track + m_fillRenderId = renderer.registerEntry(); // Fill bar + m_textRenderId = renderer.registerEntry(); // Text + m_registered = true; + // Set destroy callback to unregister all + setDestroyCallback([&renderer, fillId = m_fillRenderId, textId = m_textRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(fillId); + renderer.unregisterEntry(textId); + }); + } + + // Retained mode: only publish if changed + int bgLayer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, bgColor, bgLayer); // Render fill based on progress + int fillLayer = renderer.nextLayer(); if (horizontal) { float fillWidth = progress * width; - renderer.drawRect(absX, absY, fillWidth, height, fillColor); + renderer.updateRect(m_fillRenderId, absX, absY, fillWidth, height, fillColor, fillLayer); } else { float fillHeight = progress * height; - renderer.drawRect(absX, absY + height - fillHeight, width, fillHeight, fillColor); + renderer.updateRect(m_fillRenderId, absX, absY + height - fillHeight, width, fillHeight, fillColor, fillLayer); } // Render percentage text if enabled @@ -32,9 +48,10 @@ void UIProgressBar::render(UIRenderer& renderer) { oss << std::fixed << std::setprecision(0) << (progress * 100.0f) << "%"; std::string progressText = oss.str(); + int textLayer = renderer.nextLayer(); float textX = absX + width * 0.5f; float textY = absY + height * 0.5f; - renderer.drawText(textX, textY, progressText, fontSize, textColor); + renderer.updateText(m_textRenderId, textX, textY, progressText, fontSize, textColor, textLayer); } // Render children on top diff --git a/modules/UIModule/Widgets/UIProgressBar.h b/modules/UIModule/Widgets/UIProgressBar.h index 2cebb0e..652e1b0 100644 --- a/modules/UIModule/Widgets/UIProgressBar.h +++ b/modules/UIModule/Widgets/UIProgressBar.h @@ -41,6 +41,11 @@ public: uint32_t fillColor = 0x2ecc71FF; uint32_t textColor = 0xFFFFFFFF; float fontSize = 14.0f; + +private: + // Retained mode render IDs + uint32_t m_fillRenderId = 0; // Separate ID for fill bar element + uint32_t m_textRenderId = 0; // Separate ID for text element }; } // namespace grove diff --git a/modules/UIModule/Widgets/UIScrollPanel.cpp b/modules/UIModule/Widgets/UIScrollPanel.cpp index 8f98b8f..9bc8cb1 100644 --- a/modules/UIModule/Widgets/UIScrollPanel.cpp +++ b/modules/UIModule/Widgets/UIScrollPanel.cpp @@ -40,19 +40,58 @@ void UIScrollPanel::update(UIContext& ctx, float deltaTime) { void UIScrollPanel::render(UIRenderer& renderer) { if (!visible) return; + // Register with renderer on first render + // Need 7 entries: background + 4 borders + scrollbar track + scrollbar thumb + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background + m_borderTopId = renderer.registerEntry(); // Border top + m_borderBottomId = renderer.registerEntry(); // Border bottom + m_borderLeftId = renderer.registerEntry(); // Border left + m_borderRightId = renderer.registerEntry(); // Border right + m_scrollTrackId = renderer.registerEntry(); // Scrollbar track + m_scrollThumbId = renderer.registerEntry(); // Scrollbar thumb + m_registered = true; + + // Set destroy callback to unregister all entries + setDestroyCallback([&renderer, + borderTopId = m_borderTopId, + borderBottomId = m_borderBottomId, + borderLeftId = m_borderLeftId, + borderRightId = m_borderRightId, + scrollTrackId = m_scrollTrackId, + scrollThumbId = m_scrollThumbId](uint32_t id) { + renderer.unregisterEntry(id); // Background + renderer.unregisterEntry(borderTopId); + renderer.unregisterEntry(borderBottomId); + renderer.unregisterEntry(borderLeftId); + renderer.unregisterEntry(borderRightId); + renderer.unregisterEntry(scrollTrackId); + renderer.unregisterEntry(scrollThumbId); + }); + } + // Render background - renderer.drawRect(absX, absY, width, height, bgColor); + int bgLayer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, bgColor, bgLayer); // Render border if needed if (borderWidth > 0.0f) { + int borderLayer = renderer.nextLayer(); // Top border - renderer.drawRect(absX, absY, width, borderWidth, borderColor); + renderer.updateRect(m_borderTopId, absX, absY, width, borderWidth, borderColor, borderLayer); // Bottom border - renderer.drawRect(absX, absY + height - borderWidth, width, borderWidth, borderColor); + renderer.updateRect(m_borderBottomId, absX, absY + height - borderWidth, width, borderWidth, borderColor, borderLayer); // Left border - renderer.drawRect(absX, absY, borderWidth, height, borderColor); + renderer.updateRect(m_borderLeftId, absX, absY, borderWidth, height, borderColor, borderLayer); // Right border - renderer.drawRect(absX + width - borderWidth, absY, borderWidth, height, borderColor); + renderer.updateRect(m_borderRightId, absX + width - borderWidth, absY, borderWidth, height, borderColor, borderLayer); + } else { + // Hide borders by setting zero size when not needed + int borderLayer = renderer.nextLayer(); + renderer.updateRect(m_borderTopId, 0, 0, 0, 0, 0, borderLayer); + renderer.updateRect(m_borderBottomId, 0, 0, 0, 0, 0, borderLayer); + renderer.updateRect(m_borderLeftId, 0, 0, 0, 0, 0, borderLayer); + renderer.updateRect(m_borderRightId, 0, 0, 0, 0, 0, borderLayer); } // Render children with scroll offset and clipping @@ -90,6 +129,11 @@ void UIScrollPanel::render(UIRenderer& renderer) { // Render scrollbar if (showScrollbar && scrollVertical && contentHeight > height) { renderScrollbar(renderer); + } else { + // Hide scrollbar elements when not needed + int scrollLayer = renderer.nextLayer(); + renderer.updateRect(m_scrollTrackId, 0, 0, 0, 0, 0, scrollLayer); + renderer.updateRect(m_scrollThumbId, 0, 0, 0, 0, 0, scrollLayer); } } @@ -180,14 +224,16 @@ void UIScrollPanel::getScrollbarRect(float& outX, float& outY, float& outW, floa void UIScrollPanel::renderScrollbar(UIRenderer& renderer) { // Render scrollbar background track float trackX = absX + width - scrollbarWidth; - renderer.drawRect(trackX, absY, scrollbarWidth, height, scrollbarBgColor); + int trackLayer = renderer.nextLayer(); + renderer.updateRect(m_scrollTrackId, trackX, absY, scrollbarWidth, height, scrollbarBgColor, trackLayer); // Render scrollbar thumb float sbX, sbY, sbW, sbH; getScrollbarRect(sbX, sbY, sbW, sbH); // Use hover color if hovered (would need ctx passed to render, simplified for now) - renderer.drawRect(sbX, sbY, sbW, sbH, scrollbarColor); + int thumbLayer = renderer.nextLayer(); + renderer.updateRect(m_scrollThumbId, sbX, sbY, sbW, sbH, scrollbarColor, thumbLayer); } void UIScrollPanel::updateScrollInteraction(UIContext& ctx) { diff --git a/modules/UIModule/Widgets/UIScrollPanel.h b/modules/UIModule/Widgets/UIScrollPanel.h index 4babf14..2803621 100644 --- a/modules/UIModule/Widgets/UIScrollPanel.h +++ b/modules/UIModule/Widgets/UIScrollPanel.h @@ -89,6 +89,14 @@ public: private: void renderScrollbar(UIRenderer& renderer); void updateScrollInteraction(UIContext& ctx); + + // Retained mode render IDs + uint32_t m_borderTopId = 0; + uint32_t m_borderBottomId = 0; + uint32_t m_borderLeftId = 0; + uint32_t m_borderRightId = 0; + uint32_t m_scrollTrackId = 0; + uint32_t m_scrollThumbId = 0; }; } // namespace grove diff --git a/modules/UIModule/Widgets/UISlider.cpp b/modules/UIModule/Widgets/UISlider.cpp index f839a65..2455af7 100644 --- a/modules/UIModule/Widgets/UISlider.cpp +++ b/modules/UIModule/Widgets/UISlider.cpp @@ -22,16 +22,32 @@ void UISlider::update(UIContext& ctx, float deltaTime) { } void UISlider::render(UIRenderer& renderer) { + // Register with renderer on first render (need 3 entries: track, fill, handle) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Track (background) + m_fillRenderId = renderer.registerEntry(); // Fill (progress) + m_handleRenderId = renderer.registerEntry(); // Handle + m_registered = true; + // Set destroy callback to unregister all three + setDestroyCallback([&renderer, fillId = m_fillRenderId, handleId = m_handleRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(fillId); + renderer.unregisterEntry(handleId); + }); + } + // Render track (background) - renderer.drawRect(absX, absY, width, height, trackColor); + int trackLayer = renderer.nextLayer(); + renderer.updateRect(m_renderId, absX, absY, width, height, trackColor, trackLayer); // Render fill (progress) + int fillLayer = renderer.nextLayer(); if (horizontal) { float fillWidth = (value - minValue) / (maxValue - minValue) * width; - renderer.drawRect(absX, absY, fillWidth, height, fillColor); + renderer.updateRect(m_fillRenderId, absX, absY, fillWidth, height, fillColor, fillLayer); } else { float fillHeight = (value - minValue) / (maxValue - minValue) * height; - renderer.drawRect(absX, absY + height - fillHeight, width, fillHeight, fillColor); + renderer.updateRect(m_fillRenderId, absX, absY + height - fillHeight, width, fillHeight, fillColor, fillLayer); } // Render handle @@ -39,13 +55,16 @@ void UISlider::render(UIRenderer& renderer) { calculateHandlePosition(handleX, handleY); // Handle is a small square + int handleLayer = renderer.nextLayer(); float halfHandle = handleSize * 0.5f; - renderer.drawRect( + renderer.updateRect( + m_handleRenderId, handleX - halfHandle, handleY - halfHandle, handleSize, handleSize, - handleColor + handleColor, + handleLayer ); // Render children on top diff --git a/modules/UIModule/Widgets/UISlider.h b/modules/UIModule/Widgets/UISlider.h index 9abbc41..f1b442e 100644 --- a/modules/UIModule/Widgets/UISlider.h +++ b/modules/UIModule/Widgets/UISlider.h @@ -75,6 +75,10 @@ private: * @brief Calculate value from mouse position */ float calculateValueFromPosition(float x, float y) const; + + // Retained mode render IDs (track uses m_renderId from base class) + uint32_t m_fillRenderId = 0; // Fill element + uint32_t m_handleRenderId = 0; // Handle element }; } // namespace grove diff --git a/modules/UIModule/Widgets/UITextInput.cpp b/modules/UIModule/Widgets/UITextInput.cpp index 7c9c467..47942f3 100644 --- a/modules/UIModule/Widgets/UITextInput.cpp +++ b/modules/UIModule/Widgets/UITextInput.cpp @@ -30,43 +30,81 @@ void UITextInput::update(UIContext& ctx, float deltaTime) { } void UITextInput::render(UIRenderer& renderer) { + // Register with renderer on first render (need 5 entries: bg, border, text, placeholder, cursor) + if (!m_registered) { + m_renderId = renderer.registerEntry(); // Background + m_borderRenderId = renderer.registerEntry(); // Border + m_textRenderId = renderer.registerEntry(); // Text content + m_placeholderRenderId = renderer.registerEntry(); // Placeholder text + m_cursorRenderId = renderer.registerEntry(); // Cursor + m_registered = true; + // Set destroy callback to unregister all entries + setDestroyCallback([&renderer, + borderId = m_borderRenderId, + textId = m_textRenderId, + placeholderId = m_placeholderRenderId, + cursorId = m_cursorRenderId](uint32_t id) { + renderer.unregisterEntry(id); + renderer.unregisterEntry(borderId); + renderer.unregisterEntry(textId); + renderer.unregisterEntry(placeholderId); + renderer.unregisterEntry(cursorId); + }); + } + const TextInputStyle& style = getCurrentStyle(); + // Retained mode: update entries with current state + int bgLayer = renderer.nextLayer(); + // Render background - renderer.drawRect(absX, absY, width, height, style.bgColor); + renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer); // Render border + int borderLayer = renderer.nextLayer(); uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor; - // TODO: Implement proper border rendering - // For now, render as thin line at bottom - renderer.drawRect(absX, absY + height - style.borderWidth, - width, style.borderWidth, borderColor); + renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth, + width, style.borderWidth, borderColor, borderLayer); // Calculate text area float textX = absX + PADDING; float textY = absY + height * 0.5f; - float textAreaWidth = width - 2 * PADDING; // Render text or placeholder - if (text.empty() && !placeholder.empty() && !isFocused) { - // Show placeholder - renderer.drawText(textX, textY, placeholder, fontSize, style.placeholderColor); + bool showPlaceholder = text.empty() && !placeholder.empty() && !isFocused; + + if (showPlaceholder) { + // Show placeholder, hide text and cursor + int placeholderLayer = renderer.nextLayer(); + renderer.updateText(m_placeholderRenderId, textX, textY, placeholder, + fontSize, style.placeholderColor, placeholderLayer); + // Hide text and cursor by setting empty/zero-size + renderer.updateText(m_textRenderId, 0, 0, "", fontSize, 0, 0); + renderer.updateRect(m_cursorRenderId, 0, 0, 0, 0, 0, 0); } else { - // Show actual text - std::string displayText = getDisplayText(); + // Show actual text, hide placeholder + renderer.updateText(m_placeholderRenderId, 0, 0, "", fontSize, 0, 0); + std::string visibleText = getVisibleText(); + int textLayer = renderer.nextLayer(); if (!visibleText.empty()) { - renderer.drawText(textX - scrollOffset, textY, visibleText, - fontSize, style.textColor); + renderer.updateText(m_textRenderId, textX - scrollOffset, textY, visibleText, + fontSize, style.textColor, textLayer); + } else { + renderer.updateText(m_textRenderId, 0, 0, "", fontSize, 0, 0); } // Render cursor if focused and visible + int cursorLayer = renderer.nextLayer(); if (isFocused && cursorVisible) { float cursorX = textX + getCursorPixelOffset() - scrollOffset; - renderer.drawRect(cursorX, absY + PADDING, - CURSOR_WIDTH, height - 2 * PADDING, - style.cursorColor); + renderer.updateRect(m_cursorRenderId, cursorX, absY + PADDING, + CURSOR_WIDTH, height - 2 * PADDING, + style.cursorColor, cursorLayer); + } else { + // Hide cursor + renderer.updateRect(m_cursorRenderId, 0, 0, 0, 0, 0, 0); } } diff --git a/modules/UIModule/Widgets/UITextInput.h b/modules/UIModule/Widgets/UITextInput.h index fadbfec..aa4fdd4 100644 --- a/modules/UIModule/Widgets/UITextInput.h +++ b/modules/UIModule/Widgets/UITextInput.h @@ -183,6 +183,12 @@ private: * @brief Update scroll offset to keep cursor visible */ void updateScrollOffset(); + + // Retained mode render IDs (m_renderId from base class is used for background) + uint32_t m_borderRenderId = 0; // Border element + uint32_t m_textRenderId = 0; // Text content element + uint32_t m_placeholderRenderId = 0; // Placeholder text element + uint32_t m_cursorRenderId = 0; // Cursor element }; } // namespace grove