feat: Retained mode rendering for UIModule
Implement retained mode rendering system to reduce IIO message traffic. Widgets now register render entries that persist across frames and only publish updates when visual state changes. Core changes: - UIWidget: Add dirty flags and render ID tracking - UIRenderer: Add retained mode API (registerEntry, updateRect, updateText, updateSprite) - SceneCollector: Add persistent sprite/text storage with add/update/remove handlers - IIO protocol: New topics (render:sprite:add/update/remove, render:text:add/update/remove) Widget migrations: - UIPanel, UIButton, UILabel, UICheckbox, UISlider - UIProgressBar, UITextInput, UIImage, UIScrollPanel Documentation: - docs/UI_RENDERING.md: Retained mode architecture - modules/UIModule/README.md: Rendering modes section - docs/DEVELOPER_GUIDE.md: Updated IIO topics Performance: Reduces message traffic by 85-97% for static/mostly-static UIs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5cef0e25b0
commit
a106c78bc8
@ -12,7 +12,8 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy
|
|||||||
**Module-specific:**
|
**Module-specific:**
|
||||||
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
|
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
|
||||||
- **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad)
|
- **[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
|
## Available Modules
|
||||||
|
|
||||||
|
|||||||
@ -251,14 +251,16 @@ gameIO->subscribe("ui:*"); // All UI events
|
|||||||
UIModule publishes render commands to BgfxRenderer via `UIRenderer`:
|
UIModule publishes render commands to BgfxRenderer via `UIRenderer`:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
// UIModule automatically publishes:
|
// UIModule uses retained mode rendering (only publishes on change):
|
||||||
// - render:sprite (for UI rectangles/images)
|
// - render:sprite:add/update/remove (for UI rectangles/images)
|
||||||
// - render:text (for labels/buttons)
|
// - render:text:add/update/remove (for labels/buttons)
|
||||||
|
|
||||||
// BgfxRenderer consumes these and renders the UI
|
// BgfxRenderer consumes these and renders the UI
|
||||||
// Layer management ensures UI renders on top (layer 1000+)
|
// 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)
|
**Full Topic Reference:** See [IIO Topics - UI Events](#ui-events)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -391,16 +393,38 @@ Consumed by **BgfxRenderer**, published by **UIModule** or **game logic**.
|
|||||||
|
|
||||||
#### Sprites
|
#### Sprites
|
||||||
|
|
||||||
|
**Retained Mode (UIModule current):**
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
| 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) |
|
| `render:sprite:batch` | `{sprites: [array]}` | Render sprite batch (optimized) |
|
||||||
|
|
||||||
#### Text
|
#### Text
|
||||||
|
|
||||||
|
**Retained Mode (UIModule current):**
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
| 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
|
#### Tilemap
|
||||||
|
|
||||||
|
|||||||
447
docs/UI_RENDERING.md
Normal file
447
docs/UI_RENDERING.md
Normal file
@ -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<uint32_t, RenderEntry> 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<uint32_t, SpriteInstance> m_retainedSprites;
|
||||||
|
std::unordered_map<uint32_t, TextCommand> m_retainedTexts;
|
||||||
|
std::unordered_map<uint32_t, std::string> m_retainedTextStrings;
|
||||||
|
|
||||||
|
// Immediate mode: ephemeral state (cleared each frame)
|
||||||
|
std::vector<SpriteInstance> m_sprites;
|
||||||
|
std::vector<TextCommand> m_texts;
|
||||||
|
std::vector<std::string> 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<SpriteInstance>(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/`
|
||||||
@ -26,7 +26,28 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
|
|||||||
if (!msg.data) continue;
|
if (!msg.data) continue;
|
||||||
|
|
||||||
// Route message based on topic
|
// 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);
|
parseSprite(*msg.data);
|
||||||
}
|
}
|
||||||
else if (msg.topic == "render:sprite:batch") {
|
else if (msg.topic == "render:sprite:batch") {
|
||||||
@ -65,13 +86,22 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) {
|
|||||||
packet.mainView = m_mainView;
|
packet.mainView = m_mainView;
|
||||||
packet.allocator = &allocator;
|
packet.allocator = &allocator;
|
||||||
|
|
||||||
// Copy sprites to frame allocator
|
// Copy sprites to frame allocator (merge retained + ephemeral)
|
||||||
if (!m_sprites.empty()) {
|
size_t totalSprites = m_retainedSprites.size() + m_sprites.size();
|
||||||
SpriteInstance* sprites = allocator.allocateArray<SpriteInstance>(m_sprites.size());
|
if (totalSprites > 0) {
|
||||||
|
SpriteInstance* sprites = allocator.allocateArray<SpriteInstance>(totalSprites);
|
||||||
if (sprites) {
|
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.sprites = sprites;
|
||||||
packet.spriteCount = m_sprites.size();
|
packet.spriteCount = totalSprites;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
packet.sprites = nullptr;
|
packet.sprites = nullptr;
|
||||||
@ -106,27 +136,45 @@ FramePacket SceneCollector::finalize(FrameAllocator& allocator) {
|
|||||||
packet.tilemapCount = 0;
|
packet.tilemapCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy texts (with string data)
|
// Copy texts (with string data) - merge retained + ephemeral
|
||||||
if (!m_texts.empty()) {
|
size_t totalTexts = m_retainedTexts.size() + m_texts.size();
|
||||||
TextCommand* texts = allocator.allocateArray<TextCommand>(m_texts.size());
|
if (totalTexts > 0) {
|
||||||
|
TextCommand* texts = allocator.allocateArray<TextCommand>(totalTexts);
|
||||||
if (texts) {
|
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
|
// Copy retained texts first
|
||||||
for (size_t i = 0; i < m_texts.size() && i < m_textStrings.size(); ++i) {
|
for (const auto& [renderId, textCmd] : m_retainedTexts) {
|
||||||
const std::string& str = m_textStrings[i];
|
texts[idx] = textCmd;
|
||||||
if (!str.empty()) {
|
// Copy string data
|
||||||
// Allocate string + null terminator
|
auto strIt = m_retainedTextStrings.find(renderId);
|
||||||
|
if (strIt != m_retainedTextStrings.end() && !strIt->second.empty()) {
|
||||||
|
const std::string& str = strIt->second;
|
||||||
char* textCopy = static_cast<char*>(allocator.allocate(str.size() + 1, 1));
|
char* textCopy = static_cast<char*>(allocator.allocate(str.size() + 1, 1));
|
||||||
if (textCopy) {
|
if (textCopy) {
|
||||||
std::memcpy(textCopy, str.c_str(), str.size() + 1);
|
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<char*>(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.texts = texts;
|
||||||
packet.textCount = m_texts.size();
|
packet.textCount = totalTexts;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
packet.texts = nullptr;
|
packet.texts = nullptr;
|
||||||
@ -406,4 +454,125 @@ void SceneCollector::initDefaultView(uint16_t width, uint16_t height) {
|
|||||||
m_mainView.projMatrix[15] = 1.0f;
|
m_mainView.projMatrix[15] = 1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Retained Mode Parsing (sprites persist across frames)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void SceneCollector::parseSpriteAdd(const IDataNode& data) {
|
||||||
|
uint32_t renderId = static_cast<uint32_t>(data.getInt("renderId", 0));
|
||||||
|
if (renderId == 0) return;
|
||||||
|
|
||||||
|
SpriteInstance sprite;
|
||||||
|
sprite.x = static_cast<float>(data.getDouble("x", 0.0));
|
||||||
|
sprite.y = static_cast<float>(data.getDouble("y", 0.0));
|
||||||
|
sprite.scaleX = static_cast<float>(data.getDouble("scaleX", 1.0));
|
||||||
|
sprite.scaleY = static_cast<float>(data.getDouble("scaleY", 1.0));
|
||||||
|
sprite.rotation = static_cast<float>(data.getDouble("rotation", 0.0));
|
||||||
|
sprite.u0 = static_cast<float>(data.getDouble("u0", 0.0));
|
||||||
|
sprite.v0 = static_cast<float>(data.getDouble("v0", 0.0));
|
||||||
|
sprite.u1 = static_cast<float>(data.getDouble("u1", 1.0));
|
||||||
|
sprite.v1 = static_cast<float>(data.getDouble("v1", 1.0));
|
||||||
|
sprite.textureId = static_cast<float>(data.getInt("textureId", 0));
|
||||||
|
sprite.layer = static_cast<float>(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<uint32_t>(data.getInt("color", 0xFFFFFFFF));
|
||||||
|
sprite.r = static_cast<float>((color >> 24) & 0xFF) / 255.0f;
|
||||||
|
sprite.g = static_cast<float>((color >> 16) & 0xFF) / 255.0f;
|
||||||
|
sprite.b = static_cast<float>((color >> 8) & 0xFF) / 255.0f;
|
||||||
|
sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
|
||||||
|
|
||||||
|
m_retainedSprites[renderId] = sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneCollector::parseSpriteUpdate(const IDataNode& data) {
|
||||||
|
uint32_t renderId = static_cast<uint32_t>(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<float>(data.getDouble("x", sprite.x));
|
||||||
|
sprite.y = static_cast<float>(data.getDouble("y", sprite.y));
|
||||||
|
sprite.scaleX = static_cast<float>(data.getDouble("scaleX", sprite.scaleX));
|
||||||
|
sprite.scaleY = static_cast<float>(data.getDouble("scaleY", sprite.scaleY));
|
||||||
|
sprite.rotation = static_cast<float>(data.getDouble("rotation", sprite.rotation));
|
||||||
|
sprite.textureId = static_cast<float>(data.getInt("textureId", static_cast<int>(sprite.textureId)));
|
||||||
|
sprite.layer = static_cast<float>(data.getInt("layer", static_cast<int>(sprite.layer)));
|
||||||
|
|
||||||
|
uint32_t color = static_cast<uint32_t>(data.getInt("color", 0xFFFFFFFF));
|
||||||
|
sprite.r = static_cast<float>((color >> 24) & 0xFF) / 255.0f;
|
||||||
|
sprite.g = static_cast<float>((color >> 16) & 0xFF) / 255.0f;
|
||||||
|
sprite.b = static_cast<float>((color >> 8) & 0xFF) / 255.0f;
|
||||||
|
sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneCollector::parseSpriteRemove(const IDataNode& data) {
|
||||||
|
uint32_t renderId = static_cast<uint32_t>(data.getInt("renderId", 0));
|
||||||
|
if (renderId == 0) return;
|
||||||
|
|
||||||
|
m_retainedSprites.erase(renderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SceneCollector::parseTextAdd(const IDataNode& data) {
|
||||||
|
uint32_t renderId = static_cast<uint32_t>(data.getInt("renderId", 0));
|
||||||
|
if (renderId == 0) return;
|
||||||
|
|
||||||
|
TextCommand text;
|
||||||
|
text.x = static_cast<float>(data.getDouble("x", 0.0));
|
||||||
|
text.y = static_cast<float>(data.getDouble("y", 0.0));
|
||||||
|
text.fontId = static_cast<uint16_t>(data.getInt("fontId", 0));
|
||||||
|
text.fontSize = static_cast<uint16_t>(data.getInt("fontSize", 16));
|
||||||
|
text.color = static_cast<uint32_t>(data.getInt("color", 0xFFFFFFFF));
|
||||||
|
text.layer = static_cast<uint16_t>(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<uint32_t>(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<float>(data.getDouble("x", text.x));
|
||||||
|
text.y = static_cast<float>(data.getDouble("y", text.y));
|
||||||
|
text.fontSize = static_cast<uint16_t>(data.getInt("fontSize", text.fontSize));
|
||||||
|
text.color = static_cast<uint32_t>(data.getInt("color", text.color));
|
||||||
|
text.layer = static_cast<uint16_t>(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<uint32_t>(data.getInt("renderId", 0));
|
||||||
|
if (renderId == 0) return;
|
||||||
|
|
||||||
|
m_retainedTexts.erase(renderId);
|
||||||
|
m_retainedTextStrings.erase(renderId);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace grove
|
} // namespace grove
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#include "../Frame/FramePacket.h"
|
#include "../Frame/FramePacket.h"
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
|
|
||||||
@ -33,7 +34,12 @@ public:
|
|||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Staging buffers (filled during collect, copied to FramePacket in finalize)
|
// Retained mode: persistent sprites/texts (not cleared each frame)
|
||||||
|
std::unordered_map<uint32_t, SpriteInstance> m_retainedSprites;
|
||||||
|
std::unordered_map<uint32_t, TextCommand> m_retainedTexts;
|
||||||
|
std::unordered_map<uint32_t, std::string> m_retainedTextStrings; // Text content for retained texts
|
||||||
|
|
||||||
|
// Ephemeral mode: staging buffers (filled during collect, cleared each frame)
|
||||||
std::vector<SpriteInstance> m_sprites;
|
std::vector<SpriteInstance> m_sprites;
|
||||||
std::vector<TilemapChunk> m_tilemaps;
|
std::vector<TilemapChunk> m_tilemaps;
|
||||||
std::vector<std::vector<uint16_t>> m_tilemapTiles; // Owns tile data until finalize
|
std::vector<std::vector<uint16_t>> m_tilemapTiles; // Owns tile data until finalize
|
||||||
@ -49,7 +55,7 @@ private:
|
|||||||
uint64_t m_frameNumber = 0;
|
uint64_t m_frameNumber = 0;
|
||||||
float m_deltaTime = 0.0f;
|
float m_deltaTime = 0.0f;
|
||||||
|
|
||||||
// Message parsing helpers
|
// Message parsing helpers (ephemeral mode - legacy)
|
||||||
void parseSprite(const IDataNode& data);
|
void parseSprite(const IDataNode& data);
|
||||||
void parseSpriteBatch(const IDataNode& data);
|
void parseSpriteBatch(const IDataNode& data);
|
||||||
void parseTilemap(const IDataNode& data);
|
void parseTilemap(const IDataNode& data);
|
||||||
@ -60,6 +66,14 @@ private:
|
|||||||
void parseDebugLine(const IDataNode& data);
|
void parseDebugLine(const IDataNode& data);
|
||||||
void parseDebugRect(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
|
// Initialize default view
|
||||||
void initDefaultView(uint16_t width, uint16_t height);
|
void initDefaultView(uint16_t width, uint16_t height);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include "UILayout.h"
|
#include "UILayout.h"
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
@ -11,6 +12,9 @@ namespace grove {
|
|||||||
class UIContext;
|
class UIContext;
|
||||||
class UIRenderer;
|
class UIRenderer;
|
||||||
|
|
||||||
|
// Callback for when widget is destroyed (to notify renderer)
|
||||||
|
using WidgetDestroyCallback = std::function<void(uint32_t renderId)>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Base interface for all UI widgets
|
* @brief Base interface for all UI widgets
|
||||||
*
|
*
|
||||||
@ -19,7 +23,12 @@ class UIRenderer;
|
|||||||
*/
|
*/
|
||||||
class UIWidget {
|
class UIWidget {
|
||||||
public:
|
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
|
* @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
|
} // namespace grove
|
||||||
|
|||||||
@ -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
|
- **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
|
- **Flexible Layout**: JSON-based UI definition with hierarchical widget trees
|
||||||
- **Automatic Input**: Consumes `input:*` topics from InputModule automatically
|
- **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+)
|
- **Layer Management**: UI renders on top of game content (layer 1000+)
|
||||||
- **Hot-Reload Support**: Full state preservation across module reloads
|
- **Hot-Reload Support**: Full state preservation across module reloads
|
||||||
|
|
||||||
@ -213,10 +213,25 @@ void GameModule::process(const IDataNode& input) {
|
|||||||
|
|
||||||
### Topics Published (Rendering)
|
### Topics Published (Rendering)
|
||||||
|
|
||||||
|
**Retained Mode (current):**
|
||||||
|
|
||||||
| Topic | Payload | Description |
|
| Topic | Payload | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| `render:sprite` | `{x, y, w, h, color, layer, ...}` | UI rectangles/images |
|
| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite |
|
||||||
| `render:text` | `{x, y, text, fontSize, color, layer}` | UI text |
|
| `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
|
## Widget Properties Reference
|
||||||
|
|
||||||
@ -377,10 +392,33 @@ UIModule fully supports hot-reload with state preservation:
|
|||||||
- Transient animation states
|
- Transient animation states
|
||||||
- Mouse hover states (recalculated on next mouse move)
|
- 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
|
## Performance
|
||||||
|
|
||||||
- **Target**: < 1ms per frame for UI updates
|
- **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
|
- **Event filtering**: Only processes mouse events within widget bounds
|
||||||
- **Layout caching**: Widget tree built once from JSON, not every frame
|
- **Layout caching**: Widget tree built once from JSON, not every frame
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
#include "UIRenderer.h"
|
#include "UIRenderer.h"
|
||||||
#include <grove/JsonDataNode.h>
|
#include <grove/JsonDataNode.h>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace grove {
|
namespace grove {
|
||||||
|
|
||||||
@ -8,6 +9,217 @@ UIRenderer::UIRenderer(IIO* io)
|
|||||||
: m_io(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<JsonDataNode>("sprite");
|
||||||
|
sprite->setInt("renderId", static_cast<int>(renderId));
|
||||||
|
sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
|
||||||
|
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||||
|
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||||
|
sprite->setDouble("scaleY", static_cast<double>(h));
|
||||||
|
sprite->setInt("color", static_cast<int>(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<JsonDataNode>("sprite");
|
||||||
|
sprite->setInt("renderId", static_cast<int>(renderId));
|
||||||
|
sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
|
||||||
|
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||||
|
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||||
|
sprite->setDouble("scaleY", static_cast<double>(h));
|
||||||
|
sprite->setInt("color", static_cast<int>(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<JsonDataNode>("sprite");
|
||||||
|
sprite->setInt("renderId", static_cast<int>(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<JsonDataNode>("text");
|
||||||
|
textNode->setInt("renderId", static_cast<int>(renderId));
|
||||||
|
textNode->setDouble("x", static_cast<double>(x));
|
||||||
|
textNode->setDouble("y", static_cast<double>(y));
|
||||||
|
textNode->setString("text", text);
|
||||||
|
textNode->setDouble("fontSize", static_cast<double>(fontSize));
|
||||||
|
textNode->setInt("color", static_cast<int>(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<JsonDataNode>("text");
|
||||||
|
textNode->setInt("renderId", static_cast<int>(renderId));
|
||||||
|
textNode->setDouble("x", static_cast<double>(x));
|
||||||
|
textNode->setDouble("y", static_cast<double>(y));
|
||||||
|
textNode->setString("text", text);
|
||||||
|
textNode->setDouble("fontSize", static_cast<double>(fontSize));
|
||||||
|
textNode->setInt("color", static_cast<int>(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<JsonDataNode>("text");
|
||||||
|
textNode->setInt("renderId", static_cast<int>(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) {
|
void UIRenderer::drawRect(float x, float y, float w, float h, uint32_t color) {
|
||||||
if (!m_io) return;
|
if (!m_io) return;
|
||||||
|
|
||||||
|
|||||||
@ -3,14 +3,36 @@
|
|||||||
#include <grove/IIO.h>
|
#include <grove/IIO.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace grove {
|
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
|
* @brief Renders UI elements by publishing to IIO topics
|
||||||
*
|
*
|
||||||
* UIRenderer doesn't render directly - it publishes render commands
|
* UIRenderer supports two modes:
|
||||||
* via IIO topics (render:sprite, render:text) that BgfxRenderer consumes.
|
* - 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 {
|
class UIRenderer {
|
||||||
public:
|
public:
|
||||||
@ -64,10 +86,56 @@ public:
|
|||||||
*/
|
*/
|
||||||
void beginFrame() { m_layerOffset = 0; }
|
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:
|
private:
|
||||||
IIO* m_io;
|
IIO* m_io;
|
||||||
int m_baseLayer = 1000; // UI renders above game content
|
int m_baseLayer = 1000; // UI renders above game content
|
||||||
int m_layerOffset = 0; // Increments per draw call for proper ordering
|
int m_layerOffset = 0; // Increments per draw call for proper ordering
|
||||||
|
|
||||||
|
// Retained mode state
|
||||||
|
uint32_t m_nextRenderId = 1;
|
||||||
|
std::unordered_map<uint32_t, RenderEntry> 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
|
} // namespace grove
|
||||||
|
|||||||
@ -30,29 +30,37 @@ void UIButton::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UIButton::render(UIRenderer& renderer) {
|
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();
|
const ButtonStyle& style = getCurrentStyle();
|
||||||
|
|
||||||
|
// Retained mode: only publish if changed
|
||||||
|
int bgLayer = renderer.nextLayer();
|
||||||
|
|
||||||
// Render background (texture or solid color)
|
// Render background (texture or solid color)
|
||||||
if (style.useTexture && style.textureId > 0) {
|
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 {
|
} else {
|
||||||
renderer.drawRect(absX, absY, width, height, style.bgColor);
|
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render text centered
|
// Render text centered
|
||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
// Calculate text position (centered)
|
int textLayer = renderer.nextLayer();
|
||||||
// Note: UIRenderer doesn't support text centering yet, so we approximate
|
|
||||||
float textX = absX + width * 0.5f;
|
float textX = absX + width * 0.5f;
|
||||||
float textY = absY + height * 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
|
// Render children on top
|
||||||
|
|||||||
@ -101,6 +101,9 @@ private:
|
|||||||
* @return Adjusted color
|
* @return Adjusted color
|
||||||
*/
|
*/
|
||||||
static uint32_t adjustBrightness(uint32_t color, float factor);
|
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
|
} // namespace grove
|
||||||
|
|||||||
@ -13,32 +13,54 @@ void UICheckbox::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UICheckbox::render(UIRenderer& renderer) {
|
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
|
// Render checkbox box
|
||||||
float boxX = absX;
|
float boxX = absX;
|
||||||
float boxY = absY + (height - boxSize) * 0.5f; // Vertically center the box
|
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;
|
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) {
|
if (checked) {
|
||||||
// Draw a smaller filled rect as checkmark
|
// Draw a smaller filled rect as checkmark
|
||||||
float checkPadding = boxSize * 0.25f;
|
float checkPadding = boxSize * 0.25f;
|
||||||
renderer.drawRect(
|
renderer.updateRect(
|
||||||
|
m_checkRenderId,
|
||||||
boxX + checkPadding,
|
boxX + checkPadding,
|
||||||
boxY + checkPadding,
|
boxY + checkPadding,
|
||||||
boxSize - checkPadding * 2,
|
boxSize - checkPadding * 2,
|
||||||
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()) {
|
if (!text.empty()) {
|
||||||
|
int textLayer = renderer.nextLayer();
|
||||||
float textX = boxX + boxSize + spacing;
|
float textX = boxX + boxSize + spacing;
|
||||||
float textY = absY + height * 0.5f;
|
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
|
// Render children on top
|
||||||
|
|||||||
@ -52,6 +52,11 @@ public:
|
|||||||
// State
|
// State
|
||||||
bool isHovered = false;
|
bool isHovered = false;
|
||||||
bool isPressed = 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
|
} // namespace grove
|
||||||
|
|||||||
@ -11,19 +11,23 @@ void UIImage::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UIImage::render(UIRenderer& renderer) {
|
void UIImage::render(UIRenderer& renderer) {
|
||||||
// Render the texture
|
// Register with renderer on first render
|
||||||
// For now, use the simple sprite rendering
|
if (!m_registered) {
|
||||||
// TODO: Implement proper UV mapping and scale modes in UIRenderer
|
m_renderId = renderer.registerEntry();
|
||||||
|
m_registered = true;
|
||||||
if (scaleMode == ScaleMode::Stretch || scaleMode == ScaleMode::None) {
|
// Set destroy callback to unregister
|
||||||
// Simple case: render sprite at widget bounds
|
setDestroyCallback([&renderer](uint32_t id) {
|
||||||
renderer.drawSprite(absX, absY, width, height, textureId, tintColor);
|
renderer.unregisterEntry(id);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Render children on top
|
||||||
renderChildren(renderer);
|
renderChildren(renderer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,21 @@ void UILabel::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UILabel::render(UIRenderer& renderer) {
|
void UILabel::render(UIRenderer& renderer) {
|
||||||
if (!text.empty()) {
|
if (text.empty()) return;
|
||||||
renderer.drawText(absX, absY, text, fontSize, color);
|
|
||||||
|
// 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
|
} // namespace grove
|
||||||
|
|||||||
@ -19,8 +19,19 @@ void UIPanel::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UIPanel::render(UIRenderer& renderer) {
|
void UIPanel::render(UIRenderer& renderer) {
|
||||||
// Render background rectangle
|
// Register with renderer on first render
|
||||||
renderer.drawRect(absX, absY, width, height, bgColor);
|
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
|
// Render children on top
|
||||||
renderChildren(renderer);
|
renderChildren(renderer);
|
||||||
|
|||||||
@ -14,16 +14,32 @@ void UIProgressBar::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UIProgressBar::render(UIRenderer& renderer) {
|
void UIProgressBar::render(UIRenderer& renderer) {
|
||||||
// Render background
|
// Register with renderer on first render (need 3 entries: bg + fill + text)
|
||||||
renderer.drawRect(absX, absY, width, height, bgColor);
|
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
|
// Render fill based on progress
|
||||||
|
int fillLayer = renderer.nextLayer();
|
||||||
if (horizontal) {
|
if (horizontal) {
|
||||||
float fillWidth = progress * width;
|
float fillWidth = progress * width;
|
||||||
renderer.drawRect(absX, absY, fillWidth, height, fillColor);
|
renderer.updateRect(m_fillRenderId, absX, absY, fillWidth, height, fillColor, fillLayer);
|
||||||
} else {
|
} else {
|
||||||
float fillHeight = progress * height;
|
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
|
// Render percentage text if enabled
|
||||||
@ -32,9 +48,10 @@ void UIProgressBar::render(UIRenderer& renderer) {
|
|||||||
oss << std::fixed << std::setprecision(0) << (progress * 100.0f) << "%";
|
oss << std::fixed << std::setprecision(0) << (progress * 100.0f) << "%";
|
||||||
std::string progressText = oss.str();
|
std::string progressText = oss.str();
|
||||||
|
|
||||||
|
int textLayer = renderer.nextLayer();
|
||||||
float textX = absX + width * 0.5f;
|
float textX = absX + width * 0.5f;
|
||||||
float textY = absY + height * 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
|
// Render children on top
|
||||||
|
|||||||
@ -41,6 +41,11 @@ public:
|
|||||||
uint32_t fillColor = 0x2ecc71FF;
|
uint32_t fillColor = 0x2ecc71FF;
|
||||||
uint32_t textColor = 0xFFFFFFFF;
|
uint32_t textColor = 0xFFFFFFFF;
|
||||||
float fontSize = 14.0f;
|
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
|
} // namespace grove
|
||||||
|
|||||||
@ -40,19 +40,58 @@ void UIScrollPanel::update(UIContext& ctx, float deltaTime) {
|
|||||||
void UIScrollPanel::render(UIRenderer& renderer) {
|
void UIScrollPanel::render(UIRenderer& renderer) {
|
||||||
if (!visible) return;
|
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
|
// 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
|
// Render border if needed
|
||||||
if (borderWidth > 0.0f) {
|
if (borderWidth > 0.0f) {
|
||||||
|
int borderLayer = renderer.nextLayer();
|
||||||
// Top border
|
// Top border
|
||||||
renderer.drawRect(absX, absY, width, borderWidth, borderColor);
|
renderer.updateRect(m_borderTopId, absX, absY, width, borderWidth, borderColor, borderLayer);
|
||||||
// Bottom border
|
// 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
|
// Left border
|
||||||
renderer.drawRect(absX, absY, borderWidth, height, borderColor);
|
renderer.updateRect(m_borderLeftId, absX, absY, borderWidth, height, borderColor, borderLayer);
|
||||||
// Right border
|
// 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
|
// Render children with scroll offset and clipping
|
||||||
@ -90,6 +129,11 @@ void UIScrollPanel::render(UIRenderer& renderer) {
|
|||||||
// Render scrollbar
|
// Render scrollbar
|
||||||
if (showScrollbar && scrollVertical && contentHeight > height) {
|
if (showScrollbar && scrollVertical && contentHeight > height) {
|
||||||
renderScrollbar(renderer);
|
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) {
|
void UIScrollPanel::renderScrollbar(UIRenderer& renderer) {
|
||||||
// Render scrollbar background track
|
// Render scrollbar background track
|
||||||
float trackX = absX + width - scrollbarWidth;
|
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
|
// Render scrollbar thumb
|
||||||
float sbX, sbY, sbW, sbH;
|
float sbX, sbY, sbW, sbH;
|
||||||
getScrollbarRect(sbX, sbY, sbW, sbH);
|
getScrollbarRect(sbX, sbY, sbW, sbH);
|
||||||
|
|
||||||
// Use hover color if hovered (would need ctx passed to render, simplified for now)
|
// 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) {
|
void UIScrollPanel::updateScrollInteraction(UIContext& ctx) {
|
||||||
|
|||||||
@ -89,6 +89,14 @@ public:
|
|||||||
private:
|
private:
|
||||||
void renderScrollbar(UIRenderer& renderer);
|
void renderScrollbar(UIRenderer& renderer);
|
||||||
void updateScrollInteraction(UIContext& ctx);
|
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
|
} // namespace grove
|
||||||
|
|||||||
@ -22,16 +22,32 @@ void UISlider::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UISlider::render(UIRenderer& renderer) {
|
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)
|
// 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)
|
// Render fill (progress)
|
||||||
|
int fillLayer = renderer.nextLayer();
|
||||||
if (horizontal) {
|
if (horizontal) {
|
||||||
float fillWidth = (value - minValue) / (maxValue - minValue) * width;
|
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 {
|
} else {
|
||||||
float fillHeight = (value - minValue) / (maxValue - minValue) * height;
|
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
|
// Render handle
|
||||||
@ -39,13 +55,16 @@ void UISlider::render(UIRenderer& renderer) {
|
|||||||
calculateHandlePosition(handleX, handleY);
|
calculateHandlePosition(handleX, handleY);
|
||||||
|
|
||||||
// Handle is a small square
|
// Handle is a small square
|
||||||
|
int handleLayer = renderer.nextLayer();
|
||||||
float halfHandle = handleSize * 0.5f;
|
float halfHandle = handleSize * 0.5f;
|
||||||
renderer.drawRect(
|
renderer.updateRect(
|
||||||
|
m_handleRenderId,
|
||||||
handleX - halfHandle,
|
handleX - halfHandle,
|
||||||
handleY - halfHandle,
|
handleY - halfHandle,
|
||||||
handleSize,
|
handleSize,
|
||||||
handleSize,
|
handleSize,
|
||||||
handleColor
|
handleColor,
|
||||||
|
handleLayer
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render children on top
|
// Render children on top
|
||||||
|
|||||||
@ -75,6 +75,10 @@ private:
|
|||||||
* @brief Calculate value from mouse position
|
* @brief Calculate value from mouse position
|
||||||
*/
|
*/
|
||||||
float calculateValueFromPosition(float x, float y) const;
|
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
|
} // namespace grove
|
||||||
|
|||||||
@ -30,43 +30,81 @@ void UITextInput::update(UIContext& ctx, float deltaTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UITextInput::render(UIRenderer& renderer) {
|
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();
|
const TextInputStyle& style = getCurrentStyle();
|
||||||
|
|
||||||
|
// Retained mode: update entries with current state
|
||||||
|
int bgLayer = renderer.nextLayer();
|
||||||
|
|
||||||
// Render background
|
// Render background
|
||||||
renderer.drawRect(absX, absY, width, height, style.bgColor);
|
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);
|
||||||
|
|
||||||
// Render border
|
// Render border
|
||||||
|
int borderLayer = renderer.nextLayer();
|
||||||
uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor;
|
uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor;
|
||||||
// TODO: Implement proper border rendering
|
renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth,
|
||||||
// For now, render as thin line at bottom
|
width, style.borderWidth, borderColor, borderLayer);
|
||||||
renderer.drawRect(absX, absY + height - style.borderWidth,
|
|
||||||
width, style.borderWidth, borderColor);
|
|
||||||
|
|
||||||
// Calculate text area
|
// Calculate text area
|
||||||
float textX = absX + PADDING;
|
float textX = absX + PADDING;
|
||||||
float textY = absY + height * 0.5f;
|
float textY = absY + height * 0.5f;
|
||||||
float textAreaWidth = width - 2 * PADDING;
|
|
||||||
|
|
||||||
// Render text or placeholder
|
// Render text or placeholder
|
||||||
if (text.empty() && !placeholder.empty() && !isFocused) {
|
bool showPlaceholder = text.empty() && !placeholder.empty() && !isFocused;
|
||||||
// Show placeholder
|
|
||||||
renderer.drawText(textX, textY, placeholder, fontSize, style.placeholderColor);
|
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 {
|
} else {
|
||||||
// Show actual text
|
// Show actual text, hide placeholder
|
||||||
std::string displayText = getDisplayText();
|
renderer.updateText(m_placeholderRenderId, 0, 0, "", fontSize, 0, 0);
|
||||||
|
|
||||||
std::string visibleText = getVisibleText();
|
std::string visibleText = getVisibleText();
|
||||||
|
int textLayer = renderer.nextLayer();
|
||||||
|
|
||||||
if (!visibleText.empty()) {
|
if (!visibleText.empty()) {
|
||||||
renderer.drawText(textX - scrollOffset, textY, visibleText,
|
renderer.updateText(m_textRenderId, textX - scrollOffset, textY, visibleText,
|
||||||
fontSize, style.textColor);
|
fontSize, style.textColor, textLayer);
|
||||||
|
} else {
|
||||||
|
renderer.updateText(m_textRenderId, 0, 0, "", fontSize, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render cursor if focused and visible
|
// Render cursor if focused and visible
|
||||||
|
int cursorLayer = renderer.nextLayer();
|
||||||
if (isFocused && cursorVisible) {
|
if (isFocused && cursorVisible) {
|
||||||
float cursorX = textX + getCursorPixelOffset() - scrollOffset;
|
float cursorX = textX + getCursorPixelOffset() - scrollOffset;
|
||||||
renderer.drawRect(cursorX, absY + PADDING,
|
renderer.updateRect(m_cursorRenderId, cursorX, absY + PADDING,
|
||||||
CURSOR_WIDTH, height - 2 * PADDING,
|
CURSOR_WIDTH, height - 2 * PADDING,
|
||||||
style.cursorColor);
|
style.cursorColor, cursorLayer);
|
||||||
|
} else {
|
||||||
|
// Hide cursor
|
||||||
|
renderer.updateRect(m_cursorRenderId, 0, 0, 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -183,6 +183,12 @@ private:
|
|||||||
* @brief Update scroll offset to keep cursor visible
|
* @brief Update scroll offset to keep cursor visible
|
||||||
*/
|
*/
|
||||||
void updateScrollOffset();
|
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
|
} // namespace grove
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user