This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Building a Graph Tool

Step-by-step guide to creating a custom visual graph editor using the gs_graphcanvas framework.

Building a Graph Tool with gs_graphcanvas

This lesson walks through the full process of creating a new visual graph editor tool using the gs_graphcanvas framework. By the end, you will have a working editor with nodes, a palette, save/load, undo/redo, variables, and runtime execution.

The pipeline is proven across three implementations: the Dialogue Editor (FlowGraph), the Audio Event Graph (DataFlowGraph), and the Unit Action Graph (StateMachineGraph).

What You Provide vs What the Framework Gives You

You provide (per tool):

  1. A GraphSystemDescriptor — identity and topology choice
  2. A GraphContext subclass — domain-specific data types (if any)
  3. Node types — registered via macros
  4. An editor window — MainWindow subclass (or direct use)
  5. An editor system component — reflects nodes, registers the view pane
  6. A runtime component — (optional) bridges graphs to game systems

The framework gives you (free):

  • Full editor window with menus, toolbar, palette, canvas
  • Multi-document tabs with dirty tracking
  • Save/load (XML wrapper + binary graph buffer)
  • Undo/redo (snapshot-based, per-graph)
  • Variable system (declarations, Get/Set nodes, references, drag-to-canvas)
  • Inspector panel (auto-generated from node EditContext)
  • Node palette with drag-drop
  • Full-window page system
  • Three execution engines (flow, data-flow, state machine)
  • Copy/paste, selection, bookmarks

 

Pages

  1. Step 1: Choose Your Topology
  2. Step 2: Create the Descriptor
  3. Step 3: Create the GraphContext
  4. Step 4: Create Nodes
  5. Step 5: Create the Editor Window
  6. Step 6: Register the System Component
  7. Step 7: Build Runtime (Optional)
  8. Pitfalls Checklist

Step 1: Choose Your Topology

This is the fundamental design decision. It determines connections, execution model, and slot types.

TopologyUse CaseExecutionConnections
FlowGraphSequential processes, dialogue trees, scriptingStep/wait/resume via FlowIn/FlowOutDirectional (left-to-right)
DataFlowGraphSignal processing, audio, shaders, procedural generationTopological dirty-propagationDirectional (left-to-right)
StateMachineGraphState machines, behavior trees, HFSMTick-based state transitionsMultidirectional (perimeter arrows)

Key constraints:

  • DataFlowGraph requires a DAG (no cycles) for topological sorting
  • StateMachineGraph inherently supports cycles
  • FlowGraph optionally supports cycles via allowConnectionLoopback

Step 2: Create the Descriptor

Create a header that returns a filled-out GraphSystemDescriptor:

#pragma once
#include <GS_GraphCanvas/GraphSystemDescriptor.h>

namespace GS_MyTool
{
    inline GS_GraphCanvas::GraphSystemDescriptor CreateMyToolDescriptor()
    {
        GS_GraphCanvas::GraphSystemDescriptor desc;

        // ===== Identity =====
        desc.systemId = "mytool";
        desc.systemName = "My Tool Editor";
        desc.fileExtension = ".mytool";
        desc.mimeType = "application/x-gs-mytool-node";
        desc.saveIdentifier = "MyToolEditor";
        desc.editorId = GraphCanvas::EditorId("MyToolEditor");

        // ===== Topology =====
        desc.topology = GS_GraphCanvas::GraphTopology::DataFlowGraph;

        // ===== Features =====
        desc.variablesEnabled = true;
        desc.allowConnectionLoopback = false;

        // ===== UI =====
        desc.windowTitle = "My Tool Editor";
        desc.menuCategory = "GS Tools";

        return desc;
    }
}

Every field in the descriptor is documented in the Descriptor & Topology reference.


Step 3: Create the GraphContext

Register any domain-specific data types beyond the built-in set (Bool, Int, Float, String, Vector2, Vector3, Color, EntityId).

#pragma once
#include <GS_GraphCanvas/Core/CommonDataTypes.h>
#include <GraphModel/Model/GraphContext.h>

namespace GS_MyTool
{
    class MyToolGraphContext : public GraphModel::GraphContext
    {
    public:
        MyToolGraphContext();

        static void RegisterDataTypes(GraphModel::GraphContextPtr context)
        {
            // Built-in CommonDataTypes are registered automatically.
            // Add domain-specific types here if needed:
            // context->RegisterDataType(MyCustomEnum, azrtti_typeid<MyStruct>(),
            //     MyStruct{}, "MyCustomType");
        }
    };
}

If your tool only uses the built-in types, the context subclass can be minimal.


Step 4: Create Nodes

Basic Node Template

#pragma once
#include <GS_GraphCanvas/Nodes/BaseNode.h>
#include <GS_GraphCanvas/Widgets/SimpleNodeMacros.h>
#include <GS_GraphCanvas/NodeRegistry.h>

namespace GS_MyTool
{
    class MyTool_ExampleNode : public GS_GraphCanvas::BaseNode
    {
    public:
        AZ_RTTI(MyTool_ExampleNode, "{GENERATE-UNIQUE-UUID}", GS_GraphCanvas::BaseNode);
        AZ_CLASS_ALLOCATOR(MyTool_ExampleNode, AZ::SystemAllocator);

        static constexpr const char* TITLE = "Example Node";
        static constexpr const char* CATEGORY = "Processing";

        static void Reflect(AZ::ReflectContext* context);
        const char* GetTitle() const override { return TITLE; }

    protected:
        void RegisterSlots() override
        {
            GS_INPUT_SLOT_TYPED("value_in", "Value", CommonDataTypes::Float, 0.0f);
            GS_OUTPUT_SLOT_TYPED("result_out", "Result", CommonDataTypes::Float);
        }

        float m_multiplier = 1.0f;
    };

    GS_AUTO_REGISTER_NODE_FOR(MyTool_ExampleNode, "mytool")
}

Reflect Implementation

void MyTool_ExampleNode::Reflect(AZ::ReflectContext* context)
{
    if (auto* sc = azrtti_cast<AZ::SerializeContext*>(context))
    {
        if (sc->FindClassData(azrtti_typeid<MyTool_ExampleNode>())) { return; }
        sc->Class<MyTool_ExampleNode, GS_GraphCanvas::BaseNode>()
            ->Version(1)
            ->Field("Multiplier", &MyTool_ExampleNode::m_multiplier)
            ;
    }
    if (auto* ec = azrtti_cast<AZ::EditContext*>(context))
    {
        ec->Class<MyTool_ExampleNode>("Example Node", "A processing node")
            ->ClassElement(AZ::Edit::ClassElements::EditorData, "")
                ->Attribute(AZ::Edit::Attributes::AutoExpand, true)
            ->DataElement(AZ::Edit::UIHandlers::Default, &MyTool_ExampleNode::m_multiplier,
                "Multiplier", "Scale factor")
            ;
    }
}

Implementing Execution

Choose the interface matching your topology:

FlowGraph — IExecutableNode:

FlowResult Execute(GraphExecutionContext& context) override
{
    float input = context.GetInputValue<float>(this, "value_in");
    context.SetOutputValue(this, "result_out", input * m_multiplier);
    return FlowResult::Continue;  // or Wait, Stop
}

DataFlowGraph — IDataFlowNode:

void Process(GraphExecutionContext& context) override
{
    auto inputAny = context.GetInputValueAny(this, "value_in");
    if (inputAny.empty())
    {
        context.SetOutputValueAny(this, "result_out", AZStd::any{});
        return;
    }
    float input = AZStd::any_cast<float>(inputAny);
    context.SetOutputValue(this, "result_out", input * m_multiplier);
}

StateMachineGraph — IStateMachineNode:

void OnEnter(GraphExecutionContext& context) override { /* ... */ }
void OnTick(GraphExecutionContext& context, float deltaTime) override { /* ... */ }
void OnExit(GraphExecutionContext& context) override { /* ... */ }

Step 5: Create the Editor Window

Simple: Standalone File Editor

If each graph is its own file (like the Audio Event Graph), use MainWindow directly:

class MyToolEditorWindow : public GS_GraphCanvas::MainWindow
{
public:
    MyToolEditorWindow(const GS_GraphCanvas::GraphSystemDescriptor& desc)
        : GS_GraphCanvas::MainWindow(desc)
    {}
};

This gives you a fully functional editor with no additional code.

Advanced: Container/Database Editor

If multiple graphs live in one file (like dialogue sequences or unit action layers), override file operations and add management UI:

class MyToolEditorWindow : public GS_GraphCanvas::MainWindow
{
protected:
    // Override file menu for container-level operations
    void AddFileNewAction() override;     // New container
    void AddFileOpenAction() override;    // Open container
    void AddFileSaveAction() override;    // Save entire container
    void AddFileSaveAsAction() override;

    // Add non-graph pages
    void SetupPages()
    {
        SetGraphPageTitle("Graphs");
        AddFullWindowPage("Settings", m_settingsPage);
    }

    // Container save pattern
    void SaveContainer()
    {
        for (auto& [graphId, entry] : m_openGraphs)
            CaptureGraphToAsset(graphId, entry.graphData, serializeContext);

        AZ::Utils::SaveObjectToFile(m_path, AZ::DataStream::ST_XML, &m_container);
        ClearAllDocumentDirty();
    }
};

Step 6: Register the System Component

The editor system component reflects all node types and registers the view pane:

class GS_MyToolEditorSystemComponent : public AZ::Component
{
public:
    AZ_COMPONENT(GS_MyToolEditorSystemComponent, "{UNIQUE-UUID}");

    static void Reflect(AZ::ReflectContext* context)
    {
        // Reflect all node types
        MyTool_ExampleNode::Reflect(context);
        // ... all your nodes ...

        // Reflect MIME events for palette drag-drop
        GS_GraphCanvas::NodeRegistry::Get().ReflectAllMimeEvents(context);

        // Reflect the component
        if (auto* sc = azrtti_cast<AZ::SerializeContext*>(context))
        {
            sc->Class<GS_MyToolEditorSystemComponent, AZ::Component>()
                ->Version(1);
        }
    }

    void Activate() override
    {
        AzToolsFramework::ViewPaneOptions options;
        options.paneRect = QRect(50, 50, 1200, 800);
        AzToolsFramework::RegisterViewPane<MyToolEditorWindow>(
            "My Tool Editor", "GS Tools", options);
    }

    void Deactivate() override
    {
        AzToolsFramework::UnregisterViewPane("My Tool Editor");
    }
};

Add the component descriptor to your gem’s editor module:

// In GS_MyToolEditorModule.cpp
m_descriptors.push_back(GS_MyToolEditorSystemComponent::CreateDescriptor());

Step 7: Build Runtime (Optional)

If your tool needs runtime execution (not just an editor-only design tool):

  1. Load the graph asset (directly or via a template cache for pooling)
  2. Create a GraphInstance via GraphInstance::CreateFromAsset()
  3. Create the appropriate evaluator (FlowGraphEvaluator, DataFlowGraphEvaluator, or StateMachineEvaluator)
  4. Expose an API via EBus for triggering and controlling execution

Pooling Pattern (High-Frequency Execution)

For graphs that are instantiated frequently (like audio events), follow the template cache pattern:

TemplateCache
  -> Caches GraphDocumentAsset per file path
  -> Pools idle GraphInstance objects
  -> CreateFromAsset() for new instances
  -> Return to idle pool on release

Pitfalls Checklist

Reflection

  • Every Reflect() must have a FindClassData guard (gs_graphcanvas is a static lib)
  • Every sc->Enum<T>() must be guarded — enums crash on double-registration
  • All UUIDs must be globally unique (collision = crash in ResolvePointer)
  • Polymorphic containers must use AZStd::vector<Base*> (raw pointers, not smart pointers)
  • EnableForAssetEditor goes on SerializeContext only, never EditContext
  • Field names must be unique, non-empty PascalCase strings
  • Use AZ_DISABLE_COPY on classes with polymorphic pointer vectors
  • Do not use id as a member variable name (conflicts with AZ_RTTI macro)

Data Flow

  • Inactive nodes must output AZStd::any{}, not default-constructed structs
  • Multi-input slots must be declared explicitly per-slot
  • Node auto-registration must use the correct systemId string

Editor

  • NodeRegistry.h must be included before StandardNodePaletteItem.h (MSVC requirement)
  • Editor system component must reflect all node types and call ReflectAllMimeEvents()
  • View pane must be registered in Activate() and unregistered in Deactivate()

Save/Load

  • Graph document asset must be a plain struct (not AZ::Data::AssetData)
  • Graphs must be serialized as byte buffers (not as shared_ptr fields)
  • Transient display data (cached titles, etc.) must be rebuilt after deserialization

See Also