Building RatUI: A Modern C++20 UI Framework
An overview of why I began building RatUI, the core architecture of the framework, and some of the technical decisions that went into it.
I've been working on RatUI for the past several months, and I wanted to share some of the technical decisions and architecture that went into building it.
Why Another UI Framework?
I began developing a 2D game from scratch, and I quickly ran into the need for a UI system. While looking for options, I found that most existing C++ UI frameworks were either:
- Immediate mode (like Dear ImGui) - great for tools but challenging for complex layouts
- Legacy retained mode (like Qt) - powerful but heavyweight and quick to bleed into other parts of the codebase
- Embedded JavaScript - No thanks.
I wanted something with the simplicity and renderer-agnostic design of Dear ImGui, but built around a retained-mode architecture capable of handling complex layouts, animations, and rich UI composition. So I started developing RatUI, an open-source C++20 UI framework designed for games and real-time applications.
Core Architecture
RatUI is built around a few key systems that are layered upon each other:
The Layout Engine
The layout engine uses a flexbox-inspired model, represented as a tree of LayoutNodes.
Each node has a LayoutStyle that defines how it should size and position itself relative to its children.
// Simplified example of LayoutStyle
struct LayoutStyle
{
// Container behavior
ELayoutType LayoutType{ ELayoutType::Overlay };
EAlignment ChildAlign{ EAlignment::TopLeft };
Unit Spacing{ 0_u };
// Optional grid hints
u16 GridColumns{ 0 };
u16 GridRows{ 0 };
// Positioning
EPositioningMode PositionMode{ EPositioningMode::Flow };
Edges Margin{};
Edges Padding{};
// Sizing
ESizingMode WidthMode{ ESizingMode::Content };
ESizingMode HeightMode{ ESizingMode::Content };
f32 FlexGrow{ 0.0f };
// Per-element override
EAlignment SelfAlign{ EAlignment::Inherit };
};LayoutNodes store their style, cached measurements, and store their hierarchy as a linked list.
// Simplified example of LayoutResult
struct LayoutResult
{
Vec2<Unit> MeasuredSize;
Rect<Unit> FinalRect;
};
// Simplified example of LayoutNode
struct LayoutNode
{
LayoutStyle Style;
LayoutResult Layout; // Cached layout results from the last measure/arrange pass
// Hierarchy as a linked list to avoid doing heap allocations for children like `std::vector<LayoutNode*> Children;`
LayoutNode* Parent{ nullptr };
LayoutNode* FirstChild{ nullptr };
LayoutNode* NextSibling{ nullptr };
bool IsDirty{ true };
};Layout is computed in two passes:
- Measure pass - determine intrinsic sizes bottom-up
- Layout pass - assign positions and final sizes top-down
This approach is more error-prone and complex than an immediate-mode system, but it pays off by allowing widgets to maintain persistent state and enables more complex layouts.
Scene and Widget System
While the layout engine is strictly responsible for sizing and positioning, Widgets are responsible for logic and rendering.
The Scene stores the widgets and layout nodes in contiguous pools for cache efficiency and pointer stability.
Each widget and layout node have an associated PoolID which stores the index into the pool and a generation counter to detect stale references.
This allows widgets and layout nodes to reference each other without risking dangling pointers.
// Simplified example of Scene
class Scene
{
public:
// Pools for widgets and layout nodes
Pool<UniquePtr<IWidget>> Widgets;
Pool<LayoutNode> LayoutNodes;
// Root layout node
PoolID RootNode;
void Update()
{
// Measure and layout the root node
LayoutNode& root = LayoutNodes[RootNode];
MeasureLayoutNode(root, Vec2<Unit>{ MaxUnit, MaxUnit });
ArrangeLayoutNode(root, Rect<Unit>{ Vec2<Unit>{0_u, 0_u}, root.MeasuredSize });
}
void Paint( DrawList& a_DrawList )
{
// For each root widget
{
widget->OnPaint( a_DrawList );
}
}
};Each Widget owns a LayoutNode and implements an OnPaint() method that draws itself and its children.
// Simplified example of IWidget interface
class IWidget
{
public:
/** @brief Called immediately after the widget is constructed and associated with a layout node. */
virtual void OnConstruct() {}
/** @brief Called immediately before the widget is destroyed and disassociated from its layout node. */
virtual void OnDestroy() {} ///< Called immediately before the widget is destroyed and disassociated from its layout node.
/** @brief Called during the layout process, allowing the widget to update its layout properties or perform calculations based on its children. */
virtual void OnSyncLayout( LayoutNode& a_Node, Vec2<Unit> a_AvailableSize ) {}
/** @brief Called when the widget should render itself and its children. */
virtual void OnPaint( DrawList& a_DrawList ) {}
// Input Events...
};
class ExampleWidget : public IWidget
{
public:
void OnPaint( DrawList& a_DrawList ) override
{
// Draw self
a_DrawList.AddRect( ... );
// Draw children
ForEachChildWidget( [&]( IWidget& child )
{
child.OnPaint( a_DrawList );
} );
}
};
Rendering System
I'm a fan of ImGui's approach to rendering, as a user, you just implement a render function that takes a list of draw commands and submits them to the GPU. No renderer coupling.
RatUI follows the same philosophy. The UI logic knows nothing about OpenGL, Vulkan, or DirectX.
Instead, it produces a DrawBatcher containing a flat vertex buffer, index buffer, and a list of DrawBatches.
Each batch maps to exactly one draw call on the backend.
Batching is important because GPU state changes and draw calls are relatively expensive.
By grouping primitives that share the same texture, shader, and render state into a single batch, RatUI can render large portions of the UI with very few draw calls.
// Simplified example of DrawBatch
struct DrawBatch
{
Optional<Rectu16> ClipRect;
Mat3f Transform;
u32 VertexByteOffset;
u32 IndexOffset;
u32 IndexCount;
};
// Simplified example of DrawBatcher
struct DrawBatcher
{
std::vector<DrawVertex> Vertices;
std::vector<u32> Indices;
std::vector<DrawBatch> Batches;
};All batches share a single vertex buffer and index buffer, which are re-uploaded each frame (e.g. GL_STREAM_DRAW for OpenGL).
Implementing your own backend is straightforward - inherit from IRenderer and implement four methods:
class IRenderer
{
public:
virtual void Execute( const DrawBatcher& a_Batcher ) = 0;
virtual TextureHandle CreateTexture( u32 a_Width, u32 a_Height,
ETextureFormat a_Format, const void* a_Data ) = 0;
virtual bool UpdateTexture( TextureID a_Texture, u32 a_MipLevel,
Rectu a_Region, const void* a_Data, size a_Bytes ) = 0;
virtual void DestroyTexture( TextureID a_Texture ) = 0;
};Though, RatUI comes with a built-in OpenGL backend with more planned in the future.
The Execute call receives the fully-built batcher for that frame.
Iterate the batches, bind the right shader, set uniforms, and call your draw indexed function.
The built-in OpenGL backend does this in roughly 150 lines.
Text Rendering
Text is the most complex part of any UI renderer, and it deserves its own section. I'll go into this in detail in a dedicated post. It was by far the hardest part of building RatUI.
RatUI is still in early development, with many features and optimizations still to be implemented, but the core architecture is in place and functional.
Plans for the future include:
- More widgets (dropdowns, sliders, text input, etc.)
- Animation system
- Theming and styling system
- More backends (Vulkan, DirectX, etc.)
Check out the RatUI repository if you want to use or contribute!