Skip to content

UI System

The UI system is a lightweight scene graph for screen-space UI. Each Scene owns a UIRoot (created via UIElementRegistry) that manages layout, input, and rendering. UI elements are not Components; they live in their own tree under the root.

Creating UI Elements

using namespace snowpulse;

auto* ui = context()->uiRegistry();
auto* root = scene()->rootUI();

auto* panel = ui->create<UIRect>("panel");
panel->color = { 0.08f, 0.08f, 0.1f, 0.9f };
panel->transform.anchorMin = { 0.5f, 0.5f };
panel->transform.anchorMax = { 0.5f, 0.5f };
panel->transform.pivot = { 0.5f, 0.5f };
panel->transform.position = { 0.0f, 0.0f };
panel->transform.size = { 420.0f, 260.0f };
root->addChild(panel);

Transforms, Anchors, and Stretching

UITransform uses normalized anchors (anchorMin/anchorMax) and a pivot to place and size elements relative to their parent. If the anchors differ, the element stretches along that axis.

The runtime computes the final rect like this:

finalSize   = transform.size + parentSize * (anchorMax - anchorMin)
anchorCenter = parentMin + parentSize * ((anchorMin + anchorMax) * 0.5)
finalMin    = anchorCenter + transform.position - transform.pivot * finalSize

On stretched axes, position offsets from the anchor center and size acts like an additive delta.

using namespace snowpulse;

auto* badge = ui->create<UIImage>("badge");
badge->sprite(assetManager()->loadSprite("ui_badge.png"));
badge->transform.anchorMin = { 1.0f, 1.0f };
badge->transform.anchorMax = { 1.0f, 1.0f };
badge->transform.pivot = { 1.0f, 1.0f };
badge->transform.position = { -12.0f, -12.0f };
root->addChild(badge);

auto* footer = ui->create<UIRect>("footer");
footer->transform.anchorMin = { 0.0f, 0.0f };
footer->transform.anchorMax = { 1.0f, 0.0f };
footer->transform.pivot = { 0.5f, 0.0f };
footer->transform.size = { 0.0f, 48.0f }; // stretch width, fixed height
root->addChild(footer);

For a fully stretched element (anchorMin = {0,0}, anchorMax = {1,1}, pivot = {0.5,0.5}):

  • position = {0, -20}, size = {0, 20} does not create symmetric 20px margins.
  • For symmetric top/bottom 20px inset, use position.y = 0 and size.y = -40.

Debug Rects

Any UIElement can render a debug overlay for its computed rect:

auto* panel = ui->create<UIElement>("panel");
panel->showDebugRect = true;
panel->debugRectColor = { 1.0f, 0.0f, 0.0f, 0.2f };

Layout Containers (Stack and Grid)

Set layoutType on a parent element to automatically arrange its children using their preferredSize().

using namespace snowpulse;

auto* menu = ui->create<UIElement>("menu");
menu->layoutType = UILayoutType::Stack;
menu->stackLayout.direction = UIStackDirection::Vertical;
menu->stackLayout.align = UIStackAlign::Center;
menu->stackLayout.spacing = 12.0f;
menu->stackLayout.padding = { 16.0f, 16.0f, 12.0f, 12.0f };
menu->stackLayout.reverse = false;
menu->transform.anchorMin = { 0.5f, 0.5f };
menu->transform.anchorMax = { 0.5f, 0.5f };
menu->transform.pivot = { 0.5f, 0.5f };
menu->transform.size = { 260.0f, 320.0f };
root->addChild(menu);

auto* play = ui->create<UIButton>("play");
play->setSprite(assetManager()->loadSprite("button_play.png"));
menu->addChild(play);

auto* options = ui->create<UIButton>("options");
options->setSprite(assetManager()->loadSprite("button_options.png"));
menu->addChild(options);
using namespace snowpulse;

auto* grid = ui->create<UIElement>("inventory");
grid->layoutType = UILayoutType::Grid;
grid->gridLayout.columns = 3;
grid->gridLayout.cellSize = { 64.0f, 64.0f };
grid->gridLayout.spacing = { 8.0f, 8.0f };
grid->gridLayout.padding = { 8.0f, 8.0f, 8.0f, 8.0f };
grid->gridLayout.horizontalAlign = UIStackAlign::Center;
grid->gridLayout.verticalAlign = UIStackAlign::Start;
grid->transform.size = { 240.0f, 240.0f };
root->addChild(grid);

for (int i = 0; i < 6; ++i) {
    auto* icon = ui->create<UIImage>();
    icon->sprite(assetManager()->loadSprite("item_icon.png"));
    grid->addChild(icon);
}

Built-in Elements

UIRect

UIRect draws a solid color quad. It is useful for panels, overlays, and blockers. It exposes the same scale9 and textureFiltering submission options as other UI quads, but has no sprite; use UIImage for a textured nine-slice panel.

using namespace snowpulse;

auto* dimmer = ui->create<UIRect>("dimmer");
dimmer->color = { 0.0f, 0.0f, 0.0f, 0.6f };
dimmer->transform.anchorMin = { 0.0f, 0.0f };
dimmer->transform.anchorMax = { 1.0f, 1.0f };
dimmer->transform.size = { 0.0f, 0.0f };
root->addChild(dimmer);

UIImage

UIImage renders a sprite atlas region or standalone texture. Enable scale9 for nine-slice scaling. The four border values are left, right, top, and bottom measurements in source-sprite pixels. When the destination is smaller than the sprite's native size, Snowpulse uniformly scales the destination borders down using the most constrained axis so corners keep their proportions. Borders remain fixed at native size when the destination is the same size or larger. UIImage also supports fillAmount (0..1) with Linear and Radial fill modes.

using namespace snowpulse;

auto* logo = ui->create<UIImage>("logo");
logo->sprite(assetManager()->loadSprite("ui_logo.png"));
logo->scale9.enabled = true;
logo->scale9.border = { 12.0f, 12.0f, 12.0f, 12.0f };
root->addChild(logo);
using namespace snowpulse;

auto* cooldown = ui->create<UIImage>("cooldown");
cooldown->sprite(assetManager()->loadSprite("ui_cooldown_ring.png"));
cooldown->fillAmount = 0.65f;
cooldown->fillType = UIImageFillType::Radial;
cooldown->radialStartAngle = 90.0f;
cooldown->radialDirection = UIImageRadialDirection::Clockwise;
root->addChild(cooldown);

Linear mode uses linearDirection (Left, Right, Top, Bottom). Radial mode uses radialStartAngle (degrees) and radialDirection (Clockwise, CounterClockwise). radialStartAngle uses 0° at +X (to the right), increasing counterclockwise.

If scale9 is active and valid, nine-slice rendering takes priority over partial fill.

UIText

UIText renders MSDF fonts with alignment controls.

UIText::effects can add a colored MSDF outline and drop shadow without a separate font atlas. Effect widths, spreads, softness, and offsets use the same local units as fontSize. Very thick effects require a font atlas generated with enough MSDF distance range and padding.

UIText::color supplies the solid fill by default. Set gradient.enabled for a two-color vertical fill across the complete visible text layout. Gradient colors replace the solid fill while outline and shadow colors remain unchanged.

Defaults:

  • wordWrap = true
  • overflowMode = UITextOverflowMode::Ellipsis (Cutoff is also available)
  • autoSize = false

autoSize only updates non-stretched axes. With wrapping enabled, width stays as the wrap constraint and height auto-fits content.

using namespace snowpulse;

auto* label = ui->create<UIText>("label");
label->font(assetManager()->loadFont("fonts/04b_19.json"));
label->fontSize = 32.0f;
label->text = "Start";
label->wordWrap = true;
label->overflowMode = UITextOverflowMode::Ellipsis;
label->autoSize = false;
label->gradient.enabled = true;
label->gradient.topColor = { 1.0f, 0.96f, 0.82f, 1.0f };
label->gradient.bottomColor = { 0.96f, 0.68f, 0.22f, 1.0f };
label->effects.outline.width = 2.0f;
label->effects.outline.color = { 0.05f, 0.08f, 0.12f, 1.0f };
label->effects.shadow.enabled = true;
label->effects.shadow.color = { 0.0f, 0.0f, 0.0f, 0.45f };
label->effects.shadow.setDirection({ 1.0f, -1.0f }, 4.0f);
label->horizontalAlign = UITextHorizontalAlign::Center;
label->verticalAlign = UITextVerticalAlign::Middle;
label->transform.size = { 200.0f, 60.0f };
root->addChild(label);

UIButton

UIButton provides hover/press states and callbacks. You can assign different sprites per state.

using namespace snowpulse;

auto* button = ui->create<UIButton>("playButton");
button->setSprite(assetManager()->loadSprite("button_idle.png"));
button->setSprite(assetManager()->loadSprite("button_hover.png"), UIButtonState::Hovered);
button->setSprite(assetManager()->loadSprite("button_down.png"), UIButtonState::Pressed);
button->onClickListener = []() {
    // Start game
};
root->addChild(button);

onPressedListener fires on pointer down. onReleasedListener fires only when the pointer is released over the button (release-inside behavior). onHoverChangedListener reports hover enter/exit.

UIScrollView

UIScrollView clips its children and handles mouse wheel scrolling. Add children to its content element.

using namespace snowpulse;

auto* scroll = ui->create<UIScrollView>("list");
scroll->axis = UIScrollAxis::Vertical;
scroll->scrollSpeed = 40.0f;
scroll->transform.size = { 300.0f, 200.0f };
root->addChild(scroll);

auto* content = scroll->content();
content->layoutType = UILayoutType::Stack;
content->stackLayout.direction = UIStackDirection::Vertical;
content->stackLayout.spacing = 8.0f;

for (int i = 0; i < 5; ++i) {
    auto* row = ui->create<UIText>();
    row->font(assetManager()->loadFont("fonts/04b_19.json"));
    row->text = "Row " + std::to_string(i + 1);
    content->addChild(row);
}

scroll->contentSize({ 300.0f, 400.0f });
scroll->scrollOffset({ 0.0f, 80.0f });

Set axis to Vertical, Horizontal, or Both. contentSize() overrides the content extent used for clamping; scrollOffset() can query or set the current offset. Setting enabled = false disables wheel handling without deactivating the element. Scroll clipping is applied to descendants of the content element.

Input, Sorting, and Cleanup

  • Set interactable to receive pointer events and blocksInput to stop clicks from passing through.
  • Use sortOrder or bringToFront()/sendToBack() for draw order.
  • Remove UI elements with Scene::destroyUIElement() (or Script::destroyUIElement()).
using namespace snowpulse;

button->sortOrder = 10;
button->blocksInput = true;
scene()->destroyUIElement(button);

World-Space UI

By default, UI renders in screen space using the logical resolution. Enable view transforms to make UI follow the camera.

using namespace snowpulse;

scene()->rootUI()->useViewTransform(true); // UI is now in world space
scene()->rootUI()->fixedSize({ 1920.0f, 1080.0f });

fixedSize() also works with screen-space UI when a fixed canvas is needed. Call clearFixedSize() to return to the app's current logical resolution.