From 43860ff421dfa94c002ddf043fc7852a233c2e72 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Tue, 21 Jul 2026 20:09:50 +0200 Subject: [PATCH 1/6] Add authored Graphite UI runtime format --- atlas/application/window.cpp | 7 +- graphite/FORMAT.md | 119 ++++++++ graphite/README.md | 4 +- graphite/layout.cpp | 57 ++++ include/atlas/runtime/context.h | 1 + include/graphite/layout.h | 5 + runtime/lib/context.cpp | 489 +++++++++++++++++++++++++++++++- 7 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 graphite/FORMAT.md diff --git a/atlas/application/window.cpp b/atlas/application/window.cpp index 9eca9357..01f6a7db 100644 --- a/atlas/application/window.cpp +++ b/atlas/application/window.cpp @@ -1870,8 +1870,11 @@ bool Window::stepFrame() { updatePipelineStateField(this->useBlending, true); - for (auto &obj : this->uiRenderables) { - obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); + if (!editorControlsEnabled || editorCameraFocused) { + for (auto &obj : this->uiRenderables) { + obj->render(getDeltaTime(), commandBuffer, + shouldRefreshPipeline(obj)); + } } this->lastViewMatrix = screenView; diff --git a/graphite/FORMAT.md b/graphite/FORMAT.md new file mode 100644 index 00000000..7b50eb91 --- /dev/null +++ b/graphite/FORMAT.md @@ -0,0 +1,119 @@ +# Atlas Graphite UI format + +Graphite UI documents use the `.aui` extension and JSON encoding. Version 1 documents have this shape: + +```json +{ + "format": "atlas.graphite.ui", + "version": 1, + "name": "Main HUD", + "canvas": { + "width": 1280, + "height": 720, + "background": [0.035, 0.04, 0.055, 1.0] + }, + "defaultFont": { + "source": "fonts/Inter-Regular.ttf", + "size": 24 + }, + "elements": [ + { + "id": "title", + "name": "Title", + "type": "text", + "position": [48, 48], + "content": "Atlas", + "fontSize": 42, + "color": [1, 1, 1, 1], + "components": [] + } + ] +} +``` + +A scene enables one or more UI documents through its top-level `ui` array. Paths are relative to the scene file. + +```json +{ + "name": "Main", + "objects": [], + "ui": [ + "ui/Main HUD.aui", + { "source": "ui/Pause Menu.aui", "enabled": true } + ] +} +``` + +In a running game, Graphite renders after the scene. In the editor scene workspace it renders only while looking through the scene camera, keeping world editing unobstructed. + +## Document fields + +- `format`: `atlas.graphite.ui`. +- `version`: currently `1`. +- `name`: display name used by the editor. +- `canvas`: reference width, height, and preview background. +- `defaultFont`: font resource inherited by text-capable elements. `source` is relative to the `.aui` file. +- `elements`: top-level element array. `root` can be used instead when a single layout owns the document. + +## Elements + +Every element accepts `id`, `name`, `type`, `position`, `style`, and `components`. Layout elements also accept `children`. + +Supported types are: + +- `text`: `content`, `font`, `fontSize`, `color`. +- `image`: `source`, `size`, `tint`. +- `button`: `label`, `size`, `padding`, `font`, `fontSize`, `enabled`. +- `checkbox`: `label`, `checked`, `enabled`, `boxSize`, `spacing`, `padding`, `font`, `fontSize`. +- `textField`: `text`, `placeholder`, `size`, `maximumWidth`, `padding`, `font`, `fontSize`. +- `column` and `row`: `children`, `size`, `padding`, `spacing`, `alignment`, `anchor`. +- `stack`: `children`, `size`, `padding`, `horizontalAlignment`, `verticalAlignment`, `anchor`. + +Colors use normalized RGBA arrays such as `[0.1, 0.2, 0.3, 1.0]`. RGB and 0–255 arrays are accepted as well. Positions and sizes use two-number arrays. + +## Styles + +The `style` object can contain `normal`, `hovered`, `pressed`, `focused`, `disabled`, and `checked` variants. A variant supports `padding`, `cornerRadius`, `background`, `borderWidth`, `border`, `foreground`, `tint`, and `fontSize`. + +```json +{ + "style": { + "normal": { + "background": [0.12, 0.13, 0.17, 0.96], + "foreground": [1, 1, 1, 1], + "borderWidth": 1, + "border": [1, 1, 1, 0.16], + "cornerRadius": 10 + }, + "hovered": { + "background": [0.18, 0.2, 0.26, 1] + } + } +} +``` + +## Script components + +Each UI element uses the same component array as a scene object. Script components therefore participate in the normal Atlas script lifecycle, variable serialization, `init`, `update`, and `atAttach` callbacks. + +```json +{ + "type": "button", + "name": "Continue Button", + "label": "Continue", + "position": [80, 420], + "size": [240, 56], + "components": [ + { + "type": "script", + "source": "scripts/ContinueButton.ts", + "className": "ContinueButton", + "variables": { + "scene": "Level One" + } + } + ] +} +``` + +The editor stores script paths relative to the `.aui` document and uses the shared `atlas script compile` output when the project runs or previews. diff --git a/graphite/README.md b/graphite/README.md index 9f179620..9f7adfa1 100644 --- a/graphite/README.md +++ b/graphite/README.md @@ -2,7 +2,9 @@ Graphite UI is the UI library for the Atlas Engine. It helps game developers create user interfaces for their games. It provides a set of tools and components that can be used to create complex and interactive user interfaces. +Authored interfaces are stored as `.aui` documents and can be attached to scenes. See [FORMAT.md](FORMAT.md) for the versioned file contract, supported elements, styles, and script components. + ## Features - A wide range of UI components, including buttons, text fields, and more. - Support for custom themes and styles. -- Easy integration with the Atlas Engine. \ No newline at end of file +- Easy integration with the Atlas Engine. diff --git a/graphite/layout.cpp b/graphite/layout.cpp index 15dc5a0c..dd180e13 100644 --- a/graphite/layout.cpp +++ b/graphite/layout.cpp @@ -83,6 +83,18 @@ void setChildTopLeft(UIObject *child, const Position2d &topLeft, } // namespace +void Column::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Column::addChild(UIObject *child) { children.push_back(child); recalculatePositions(); @@ -114,6 +126,13 @@ void Column::setProjectionMatrix(const glm::mat4 &projection) { void Column::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().column, @@ -180,6 +199,18 @@ void Row::addChild(UIObject *child) { recalculatePositions(); } +void Row::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Row::setChildren(const std::vector &newChildren) { children = newChildren; recalculatePositions(); @@ -205,6 +236,13 @@ void Row::setProjectionMatrix(const glm::mat4 &projection) { void Row::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().row, @@ -271,6 +309,18 @@ void Stack::addChild(UIObject *child) { recalculatePositions(); } +void Stack::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Stack::setChildren(const std::vector &newChildren) { children = newChildren; recalculatePositions(); @@ -296,6 +346,13 @@ void Stack::setProjectionMatrix(const glm::mat4 &projection) { void Stack::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().stack, diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 2c99ad23..ec43f43c 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -110,6 +110,7 @@ class Context { json editorTargetData = json::array(); json editorEnvironmentData = json::object(); json editorPropertySyncs = json::array(); + json editorUIData = json::array(); bool applyingPropertySyncs = false; std::function modelImportProgress; std::vector> deletedObjectReferences; diff --git a/include/graphite/layout.h b/include/graphite/layout.h index ea917b63..796263ac 100644 --- a/include/graphite/layout.h +++ b/include/graphite/layout.h @@ -94,6 +94,7 @@ class Column : public UIObject { position(pos) { recalculatePositions(); } + void initialize() override; /** @brief Renders the column background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; @@ -211,6 +212,8 @@ class Row : public UIObject { recalculatePositions(); } + void initialize() override; + /** @brief Renders the row background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; @@ -332,6 +335,8 @@ class Stack : public UIObject { recalculatePositions(); } + void initialize() override; + /** @brief Renders the stack background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 85057acd..69459be5 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -24,6 +24,10 @@ #include "aurora/terrain.h" #include "atlas/runtime/atlasScripts.h" #include "hydra/fluid.h" +#include "graphite/image.h" +#include "graphite/input.h" +#include "graphite/layout.h" +#include "graphite/text.h" #include #include #include @@ -40,6 +44,7 @@ #include #include #include +#include #include #include @@ -3719,6 +3724,207 @@ std::shared_ptr parseEffect(const json &effectData) { throw std::runtime_error("Unknown render target effect type: " + type); } +void applyGraphiteStyleVariant(const json &data, + graphite::UIStyleVariant &variant) { + if (!data.is_object()) { + return; + } + Position2d padding; + if (tryReadVec2Any(data, {"padding"}, padding)) { + variant.padding(Size2d{padding.x, padding.y}); + } + float value = 0.0f; + if (tryReadFloatAny(data, {"cornerRadius"}, value)) { + variant.cornerRadius(value); + } + Color color; + if (tryReadColorAny(data, {"background", "backgroundColor"}, color)) { + variant.background(color); + } + float borderWidth = 0.0f; + Color borderColor; + const bool hasBorderWidth = + tryReadFloatAny(data, {"borderWidth"}, borderWidth); + const bool hasBorderColor = + tryReadColorAny(data, {"border", "borderColor"}, borderColor); + if (hasBorderWidth || hasBorderColor) { + variant.border(hasBorderWidth ? borderWidth : 1.0f, + hasBorderColor ? borderColor : Color::white()); + } + if (tryReadColorAny(data, {"foreground", "foregroundColor", "color"}, + color)) { + variant.foreground(color); + } + if (tryReadColorAny(data, {"tint", "tintColor"}, color)) { + variant.tint(color); + } + if (tryReadFloatAny(data, {"fontSize"}, value)) { + variant.fontSize(value); + } +} + +graphite::UIStyle parseGraphiteStyle(const json &data) { + graphite::UIStyle style; + if (!data.is_object()) { + return style; + } + const std::array, 6> + variants{{{"normal", graphite::UIStyleState::Normal}, + {"hovered", graphite::UIStyleState::Hovered}, + {"pressed", graphite::UIStyleState::Pressed}, + {"focused", graphite::UIStyleState::Focused}, + {"disabled", graphite::UIStyleState::Disabled}, + {"checked", graphite::UIStyleState::Checked}}}; + bool foundVariant = false; + for (const auto &[name, state] : variants) { + auto it = data.find(name); + if (it == data.end()) { + continue; + } + foundVariant = true; + applyGraphiteStyleVariant(*it, style.variant(state)); + } + if (!foundVariant) { + applyGraphiteStyleVariant(data, style.normal()); + } + return style; +} + +Font loadGraphiteFont(const json &data, const std::string &baseDir) { + if (!data.is_object()) { + return {}; + } + std::string source; + tryReadStringAny(data, {"source", "path"}, source); + if (source.empty()) { + return {}; + } + int size = 24; + tryReadIntAny(data, {"size"}, size); + size = std::max(size, 1); + const std::string resolved = resolveRuntimePath(baseDir, source); + std::string name; + tryReadStringAny(data, {"name", "id"}, name); + if (name.empty()) { + name = "graphite:" + resolved + ":" + std::to_string(size); + } + static std::unordered_map cache; + const std::string key = resolved + "#" + std::to_string(size); + if (const auto found = cache.find(key); found != cache.end()) { + return found->second; + } + Resource resource = createRuntimeResource(baseDir, source, + ResourceType::Font, + "graphite-font"); + Font font = Font::fromResource(name, resource, size); + cache[key] = font; + return font; +} + +LayoutAnchor parseGraphiteAnchor(const json &data) { + std::string value; + if (data.is_string()) { + value = normalizeToken(data.get()); + } + if (value == "topcenter") + return LayoutAnchor::TopCenter; + if (value == "topright") + return LayoutAnchor::TopRight; + if (value == "centerleft") + return LayoutAnchor::CenterLeft; + if (value == "center") + return LayoutAnchor::Center; + if (value == "centerright") + return LayoutAnchor::CenterRight; + if (value == "bottomleft") + return LayoutAnchor::BottomLeft; + if (value == "bottomcenter") + return LayoutAnchor::BottomCenter; + if (value == "bottomright") + return LayoutAnchor::BottomRight; + return LayoutAnchor::TopLeft; +} + +ElementAlignment parseGraphiteAlignment(const json &data) { + std::string value; + if (data.is_string()) { + value = normalizeToken(data.get()); + } + if (value == "center" || value == "middle") + return ElementAlignment::Center; + if (value == "bottom" || value == "right" || value == "end") + return ElementAlignment::Bottom; + return ElementAlignment::Top; +} + +void inheritGraphiteDocumentDefaults(json &element, const json &font) { + if (!element.is_object()) { + return; + } + if (!element.contains("font") && font.is_object()) { + element["font"] = font; + } + auto children = element.find("children"); + if (children == element.end() || !children->is_array()) { + return; + } + for (auto &child : *children) { + inheritGraphiteDocumentDefaults(child, font); + } +} + +JsonDefinition loadGraphiteDocument(const json &value, + const std::string &baseDir) { + JsonDefinition definition; + bool enabled = true; + if (value.is_object()) { + JSON_READ_BOOL(value, "enabled", enabled); + std::string source; + tryReadStringAny(value, {"source"}, source); + if (!source.empty()) { + definition = loadJsonDefinition(source, baseDir); + } else { + definition = {.data = value, .baseDir = baseDir}; + } + } else { + definition = loadJsonDefinition(value, baseDir); + } + if (!enabled) { + definition.data = json::object(); + return definition; + } + if (!definition.data.is_object()) { + throw std::runtime_error("Graphite UI document must be an object"); + } + std::string format; + tryReadStringAny(definition.data, {"format"}, format); + if (!format.empty() && normalizeToken(format) != "atlasgraphiteui") { + throw std::runtime_error("Unsupported Graphite UI document format: " + + format); + } + int version = 1; + tryReadIntAny(definition.data, {"version"}, version); + if (version != 1) { + throw std::runtime_error("Unsupported Graphite UI document version: " + + std::to_string(version)); + } + const json defaultFont = + definition.data.contains("defaultFont") && + definition.data["defaultFont"].is_object() + ? definition.data["defaultFont"] + : json::object(); + if (definition.data.contains("root")) { + inheritGraphiteDocumentDefaults(definition.data["root"], defaultFont); + } + if (definition.data.contains("elements") && + definition.data["elements"].is_array()) { + for (auto &element : definition.data["elements"]) { + inheritGraphiteDocumentDefaults(element, defaultFont); + } + } + return definition; +} + std::shared_ptr createRenderable(Context &context, const json &objectData, const std::string &baseDir, @@ -3738,6 +3944,241 @@ createRenderable(Context &context, const json &objectData, const std::string normalizedType = normalizeToken(type); const size_t generatedIndex = context.objects.size(); + if (normalizedType == "text" || normalizedType == "image" || + normalizedType == "button" || normalizedType == "checkbox" || + normalizedType == "textfield" || normalizedType == "column" || + normalizedType == "row" || normalizedType == "stack") { + Position2d position{0.0f, 0.0f}; + tryReadVec2Any(objectData, {"position"}, position); + Position2d size{0.0f, 0.0f}; + tryReadVec2Any(objectData, {"size", "minimumSize"}, size); + Font font; + if (const json *fontData = findField(objectData, {"font"}); + fontData != nullptr) { + font = loadGraphiteFont(*fontData, baseDir); + } + graphite::UIStyle style; + const bool hasStyle = objectData.contains("style") && + objectData["style"].is_object(); + if (hasStyle) { + style = parseGraphiteStyle(objectData["style"]); + } + auto registerUIObject = [&](const auto &object) { + registerGameObject(context, *object, objectData, normalizedType, + generatedIndex); + context.objects.push_back(object); + collectPendingComponents(context, *object, objectData, baseDir, + rigidbodies, standard, joints); + return std::static_pointer_cast(object); + }; + + if (normalizedType == "text") { + std::string content; + tryReadStringAny(objectData, {"content", "text"}, content); + Color color = Color::white(); + tryReadColorAny(objectData, {"color", "textColor"}, color); + auto object = std::make_shared(content, font, color, position); + tryReadFloatAny(objectData, {"fontSize"}, object->fontSize); + if (hasStyle) + object->setStyle(style); + return registerUIObject(object); + } + + if (normalizedType == "image") { + auto object = std::make_shared(); + object->position = position; + object->size = Size2d{size.x, size.y}; + tryReadColorAny(objectData, {"tint"}, object->tint); + if (const json *source = findField(objectData, {"source", "texture"}); + source != nullptr && !isEmptyStringValue(*source)) { + object->texture = loadTextureDefinition( + *source, baseDir, TextureType::Color, false); + } + if (hasStyle) + object->setStyle(style); + return registerUIObject(object); + } + + if (normalizedType == "button") { + std::string label; + tryReadStringAny(objectData, {"label", "content", "text"}, label); + auto object = std::make_shared